001/**
002 * Copyright (C) 2006-2025 Talend Inc. - www.talend.com
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.talend.sdk.component.runtime.manager.service.http;
017
018import static java.util.Arrays.stream;
019import static java.util.Collections.emptyMap;
020import static java.util.Locale.ROOT;
021import static java.util.Optional.ofNullable;
022import static java.util.stream.Collectors.toList;
023import static java.util.stream.Collectors.toMap;
024import static java.util.stream.Stream.empty;
025import static java.util.stream.Stream.of;
026import static org.talend.sdk.component.runtime.base.lang.exception.InvocationExceptionWrapper.toRuntimeException;
027
028import java.io.UnsupportedEncodingException;
029import java.lang.reflect.Constructor;
030import java.lang.reflect.InvocationTargetException;
031import java.lang.reflect.Method;
032import java.lang.reflect.Parameter;
033import java.lang.reflect.ParameterizedType;
034import java.lang.reflect.Type;
035import java.net.URLEncoder;
036import java.nio.charset.StandardCharsets;
037import java.util.AbstractMap;
038import java.util.ArrayList;
039import java.util.Collection;
040import java.util.HashMap;
041import java.util.LinkedHashMap;
042import java.util.List;
043import java.util.Map;
044import java.util.Objects;
045import java.util.Optional;
046import java.util.function.BiFunction;
047import java.util.function.Function;
048import java.util.stream.Stream;
049
050import javax.json.bind.Jsonb;
051
052import org.talend.sdk.component.api.service.http.Base;
053import org.talend.sdk.component.api.service.http.Codec;
054import org.talend.sdk.component.api.service.http.Configurer;
055import org.talend.sdk.component.api.service.http.ConfigurerOption;
056import org.talend.sdk.component.api.service.http.ContentType;
057import org.talend.sdk.component.api.service.http.Decoder;
058import org.talend.sdk.component.api.service.http.Encoder;
059import org.talend.sdk.component.api.service.http.Header;
060import org.talend.sdk.component.api.service.http.Headers;
061import org.talend.sdk.component.api.service.http.HttpMethod;
062import org.talend.sdk.component.api.service.http.Path;
063import org.talend.sdk.component.api.service.http.Query;
064import org.talend.sdk.component.api.service.http.QueryFormat;
065import org.talend.sdk.component.api.service.http.QueryParams;
066import org.talend.sdk.component.api.service.http.Request;
067import org.talend.sdk.component.api.service.http.Response;
068import org.talend.sdk.component.api.service.http.Url;
069import org.talend.sdk.component.api.service.http.UseConfigurer;
070import org.talend.sdk.component.runtime.manager.reflect.Constructors;
071import org.talend.sdk.component.runtime.manager.reflect.ReflectionService;
072import org.talend.sdk.component.runtime.manager.service.MediaTypeComparator;
073import org.talend.sdk.component.runtime.manager.service.http.codec.CodecMatcher;
074import org.talend.sdk.component.runtime.manager.service.http.codec.JsonpDecoder;
075import org.talend.sdk.component.runtime.manager.service.http.codec.JsonpEncoder;
076
077import lombok.AllArgsConstructor;
078import lombok.Data;
079import lombok.EqualsAndHashCode;
080import lombok.RequiredArgsConstructor;
081
082public class RequestParser {
083
084    private static final String PATH_RESERVED_CHARACTERS = "=@/:!$&\'(),;~";
085
086    private static final String QUERY_RESERVED_CHARACTERS = "?/,";
087
088    private final InstanceCreator instanceCreator;
089
090    interface InstanceCreator {
091
092        <T> T buildNew(Class<? extends T> realClass);
093    }
094
095    @AllArgsConstructor
096    final static class ReflectionInstanceCreator implements InstanceCreator {
097
098        private final ReflectionService reflections;
099
100        private final Map<Class<?>, Object> services;
101
102        @Override
103        public <T> T buildNew(final Class<? extends T> realClass) {
104            try {
105                final Constructor<?> constructor = Constructors.findConstructor(realClass);
106                final Function<Map<String, String>, Object[]> paramFactory =
107                        reflections.parameterFactory(constructor, services, null);
108                return (T) constructor.newInstance(paramFactory.apply(emptyMap()));
109            } catch (final InstantiationException | IllegalAccessException e) {
110                throw new IllegalArgumentException(e);
111            } catch (final InvocationTargetException e) {
112                throw toRuntimeException(e);
113            }
114        }
115    }
116
117    private final Encoder jsonpEncoder;
118
119    private final Decoder jsonpDecoder;
120
121    private final JAXBManager jaxb = JAXB.ACTIVE ? new JAXBManager() : null;
122
123    private volatile CodecMatcher<Encoder> codecMatcher = new CodecMatcher<>();
124
125    public RequestParser(final ReflectionService reflections, final Jsonb jsonb, final Map<Class<?>, Object> services) {
126        this(new ReflectionInstanceCreator(reflections, services), jsonb);
127    }
128
129    public RequestParser(final InstanceCreator instanceCreator, final Jsonb jsonb) {
130        this.instanceCreator = instanceCreator;
131        this.jsonpEncoder = new JsonpEncoder(jsonb);
132        this.jsonpDecoder = new JsonpDecoder(jsonb);
133    }
134
135    /**
136     * Parse method annotated with @{@link Request} and construct an {@link ExecutionContext}
137     *
138     * @param method method annotated with @Request
139     * @return an http request execution context
140     */
141    public ExecutionContext parse(final Method method) {
142        if (!method.isAnnotationPresent(Request.class)) {
143            throw new IllegalStateException("Method '" + method.getName() + "' need to be annotated with @Request");
144        }
145
146        final Request request = method.getAnnotation(Request.class);
147        Configurer configurerInstance = findConfigurerInstance(method);
148        if (jaxb != null) {
149            jaxb.initJaxbContext(method);
150        }
151        final Codec codec = ofNullable(method.getAnnotation(Codec.class))
152                .orElseGet(() -> method.getDeclaringClass().getAnnotation(Codec.class));
153        final Map<String, Encoder> encoders = createEncoder(codec);
154        final Map<String, Decoder> decoders = createDecoder(codec);
155        final PathProvider pathProvider = new PathProvider();
156        final QueryParamsProvider queryParamsProvider = new QueryParamsProvider();
157        final HeadersProvider headersProvider = new HeadersProvider();
158        final Map<String, Function<Object[], Object>> configurerOptionsProvider = new HashMap<>();
159        Integer httpMethod = null;
160        Function<Object[], String> urlProvider = null;
161        Function<Object[], String> baseProvider = null;
162        BiFunction<String, Object[], Optional<byte[]>> payloadProvider = null;
163        // preCompute the execution (kind of compile phase)
164        final Parameter[] parameters = method.getParameters();
165        for (int i = 0; i < parameters.length; i++) {
166            final int index = i;
167            if (parameters[i].isAnnotationPresent(HttpMethod.class)) {
168                if (httpMethod != null) {
169                    throw new IllegalStateException(method + "has two HttpMethod parameters");
170                }
171                httpMethod = index;
172            } else if (parameters[i].isAnnotationPresent(Path.class)) {
173                final Path path = parameters[i].getAnnotation(Path.class);
174                pathProvider.pathParams.put(i, new Encodable(path.value(), path.encode()));
175            } else if (parameters[i].isAnnotationPresent(Url.class)) {
176                if (urlProvider != null) {
177                    throw new IllegalStateException(method + "has two Url parameters");
178                }
179                urlProvider = params -> String.valueOf(params[index]);
180            } else if (parameters[i].isAnnotationPresent(QueryParams.class)) {
181                final QueryParams params = parameters[i].getAnnotation(QueryParams.class);
182                queryParamsProvider.queries.put(i, new QueryEncodable("", params.encode(), params.format()));
183            } else if (parameters[i].isAnnotationPresent(Query.class)) {
184                final Query query = parameters[i].getAnnotation(Query.class);
185                queryParamsProvider.queries.put(i, new QueryEncodable(query.value(), query.encode(), query.format()));
186            } else if (parameters[i].isAnnotationPresent(Headers.class)) {
187                headersProvider.headers.put(i, "");
188            } else if (parameters[i].isAnnotationPresent(Header.class)) {
189                headersProvider.headers.put(i, parameters[i].getAnnotation(Header.class).value());
190            } else if (parameters[i].isAnnotationPresent(ConfigurerOption.class)) {
191                configurerOptionsProvider
192                        .putIfAbsent(parameters[i].getAnnotation(ConfigurerOption.class).value(),
193                                params -> params[index]);
194            } else if (parameters[i].isAnnotationPresent(Base.class)) {
195                if (baseProvider != null) {
196                    throw new IllegalStateException(method + "has two Base parameters");
197                }
198                baseProvider = params -> String.valueOf(params[index]);
199            } else { // payload
200                if (payloadProvider != null) {
201                    throw new IllegalArgumentException(method + " has two payload parameters");
202                }
203                payloadProvider = buildPayloadProvider(encoders, i);
204            }
205        }
206
207        final boolean isResponse = method.getReturnType() == Response.class;
208        final Type responseType =
209                isResponse ? ParameterizedType.class.cast(method.getGenericReturnType()).getActualTypeArguments()[0]
210                        : method.getReturnType();
211        final Integer httpMethodIndex = httpMethod;
212        final Function<Object[], String> httpMethodProvider = params -> httpMethodIndex == null ? request.method()
213                : ofNullable(params[httpMethodIndex]).map(String::valueOf).orElse(request.method());
214
215        String pathTemplate = request.path();
216        if (pathTemplate.startsWith("/")) {
217            pathTemplate = pathTemplate.substring(1, pathTemplate.length());
218        }
219        if (pathTemplate.endsWith("/")) {
220            pathTemplate = pathTemplate.substring(0, pathTemplate.length() - 1);
221        }
222
223        return new ExecutionContext(new HttpRequestCreator(httpMethodProvider, urlProvider, baseProvider, pathTemplate,
224                pathProvider, queryParamsProvider, headersProvider, payloadProvider, configurerInstance,
225                configurerOptionsProvider), responseType, isResponse, decoders);
226    }
227
228    private BiFunction<String, Object[], Optional<byte[]>> buildPayloadProvider(final Map<String, Encoder> encoders,
229            final int index) {
230
231        return (contentType, params) -> {
232            final Object payload = params[index];
233            if (payload == null) {
234                return Optional.empty();
235            }
236            if (byte[].class.isInstance(payload)) {
237                return Optional.of(byte[].class.cast(payload));
238            }
239            if (encoders.size() == 1) {
240                return Optional.of(encoders.values().iterator().next().encode(payload));
241            }
242            return Optional.of(codecMatcher.select(encoders, contentType).encode(payload));
243        };
244    }
245
246    private Configurer findConfigurerInstance(final Method m) {
247        final UseConfigurer configurer = ofNullable(m.getAnnotation(UseConfigurer.class))
248                .orElseGet(() -> m.getDeclaringClass().getAnnotation(UseConfigurer.class));
249        try {
250            return configurer == null ? null : configurer.value().getConstructor().newInstance();
251        } catch (final InstantiationException | IllegalAccessException | NoSuchMethodException e) {
252            throw new IllegalArgumentException(e);
253        } catch (final InvocationTargetException e) {
254            throw toRuntimeException(e);
255        }
256    }
257
258    static Class<?> toClassType(final Type type) {
259        Class<?> cType = null;
260        if (Class.class.isInstance(type)) {
261            cType = Class.class.cast(type);
262        } else if (ParameterizedType.class.isInstance(type)) {
263            final ParameterizedType pt = ParameterizedType.class.cast(type);
264            if (pt.getRawType() == Response.class && pt.getActualTypeArguments().length == 1
265                    && Class.class.isInstance(pt.getActualTypeArguments()[0])) {
266                cType = Class.class.cast(pt.getActualTypeArguments()[0]);
267            }
268        }
269
270        return cType;
271    }
272
273    private static String queryEncode(final String value) {
274        return componentEncode(QUERY_RESERVED_CHARACTERS, value);
275    }
276
277    private static String componentEncode(final String reservedChars, final String value) {
278        final StringBuilder buffer = new StringBuilder();
279        final StringBuilder bufferToEncode = new StringBuilder();
280        for (int i = 0; i < value.length(); i++) {
281            final char currentChar = value.charAt(i);
282            if (reservedChars.indexOf(currentChar) != -1) {
283                if (bufferToEncode.length() > 0) {
284                    buffer.append(urlEncode(bufferToEncode.toString()));
285                    bufferToEncode.setLength(0);
286                }
287                buffer.append(currentChar);
288            } else {
289                bufferToEncode.append(currentChar);
290            }
291        }
292        if (bufferToEncode.length() > 0) {
293            buffer.append(urlEncode(bufferToEncode.toString()));
294        }
295        return buffer.toString();
296    }
297
298    private static String urlEncode(final String value) {
299        try {
300            return URLEncoder.encode(value, StandardCharsets.UTF_8.name());
301        } catch (final UnsupportedEncodingException ex) {
302            throw new RuntimeException(ex);
303        }
304    }
305
306    private Map<String, Encoder> createEncoder(final Codec codec) {
307        final Map<String, Encoder> encoders = new HashMap<>();
308        if (codec != null && codec.encoder().length != 0) {
309            encoders
310                    .putAll(stream(codec.encoder())
311                            .filter(Objects::nonNull)
312                            .collect(toMap((Class<? extends Encoder> encoder) -> Optional
313                                    .ofNullable(encoder.getAnnotation(ContentType.class))
314                                    .map(ContentType::value)
315                                    .orElse("*/*"), this.instanceCreator::buildNew)));
316        }
317
318        // keep the put order
319        if (jaxb != null && !jaxb.isEmpty()) {
320            final Encoder jaxbEncoder = jaxb.newEncoder();
321            encoders.putIfAbsent("*/xml", jaxbEncoder);
322            encoders.putIfAbsent("*/*+xml", jaxbEncoder);
323        }
324
325        encoders.putIfAbsent("*/json", jsonpEncoder);
326        encoders.putIfAbsent("*/*+json", jsonpEncoder);
327        encoders
328                .putIfAbsent("*/*",
329                        value -> value == null ? new byte[0] : String.valueOf(value).getBytes(StandardCharsets.UTF_8));
330
331        return sortMap(encoders);
332
333    }
334
335    private Map<String, Decoder> createDecoder(final Codec codec) {
336        final Map<String, Decoder> decoders = new HashMap<>();
337        if (codec != null && codec.decoder().length != 0) {
338            decoders
339                    .putAll(stream(codec.decoder())
340                            .filter(Objects::nonNull)
341                            .collect(toMap((Class<? extends Decoder> decoder) -> Optional
342                                    .ofNullable(decoder.getAnnotation(ContentType.class))
343                                    .map(ContentType::value)
344                                    .orElse("*/*"), this.instanceCreator::buildNew)));
345        }
346        // add default decoders if not override by the user
347        // keep the put order
348        if (jaxb != null && !jaxb.isEmpty()) {
349            final Decoder jaxbDecoder = jaxb.newDecoder();
350            decoders.putIfAbsent("*/xml", jaxbDecoder);
351            decoders.putIfAbsent("*/*+xml", jaxbDecoder);
352        }
353        decoders.putIfAbsent("*/json", jsonpDecoder);
354        decoders.putIfAbsent("*/*+json", jsonpDecoder);
355        decoders.putIfAbsent("*/*", (value, expectedType) -> new String(value));
356
357        return sortMap(decoders);
358    }
359
360    private <T> Map<String, T> sortMap(final Map<String, T> entries) {
361        final List<String> keys = new ArrayList<>(entries.keySet());
362        keys.sort(new MediaTypeComparator(new ArrayList<>(keys)));
363        return keys.stream().collect(toMap(k -> k.toLowerCase(ROOT), entries::get, (a, b) -> {
364            throw new IllegalArgumentException(a + "/" + b);
365        }, LinkedHashMap::new));
366    }
367
368    @Data
369    private static class Encodable {
370
371        private final String name;
372
373        private final boolean encode;
374    }
375
376    @EqualsAndHashCode(callSuper = true)
377    private static class QueryEncodable extends Encodable {
378
379        private final QueryFormat format;
380
381        private QueryEncodable(final String name, final boolean encode, final QueryFormat format) {
382            super(name, encode);
383            this.format = format;
384        }
385    }
386
387    private static class QueryParamsProvider implements Function<Object[], Collection<String>> {
388
389        private final Map<Integer, QueryEncodable> queries = new LinkedHashMap<>();
390
391        @Override
392        public Collection<String> apply(final Object[] args) {
393
394            return queries.entrySet().stream().flatMap(entry -> {
395                if (entry.getValue().getName().isEmpty()) {
396                    final Map<String, ?> queryParams =
397                            args[entry.getKey()] == null ? emptyMap() : Map.class.cast(args[entry.getKey()]);
398                    if (entry.getValue().isEncode()) {
399                        return queryParams
400                                .entrySet()
401                                .stream()
402                                .filter(q -> q.getValue() != null)
403                                .flatMap(it -> mapValues(entry.getValue(), it.getKey(), it.getValue()));
404                    }
405                    return queryParams.entrySet().stream();
406                }
407                return ofNullable(args[entry.getKey()])
408                        .map(v -> mapValues(entry.getValue(), entry.getValue().getName(), v))
409                        .orElse(null);
410            }).filter(Objects::nonNull).map(kv -> kv.getKey() + "=" + kv.getValue()).collect(toList());
411        }
412
413        private Stream<AbstractMap.SimpleEntry<String, String>> mapValues(final QueryEncodable config, final String key,
414                final Object v) {
415            if (Collection.class.isInstance(v)) {
416                final Stream<String> collection = ((Collection<?>) v)
417                        .stream()
418                        .filter(Objects::nonNull)
419                        .map(String::valueOf)
420                        .map(q -> config.isEncode() ? queryEncode(q) : q);
421                switch (config.format) {
422                    case MULTI:
423                        return collection.map(q -> new AbstractMap.SimpleEntry<>(key, q));
424                    case CSV:
425                        return of(new AbstractMap.SimpleEntry<>(key, String.join(",", collection.collect(toList()))));
426                    default:
427                        throw new IllegalArgumentException("Unsupported formatting: " + config);
428                }
429            }
430
431            String value = String.valueOf(v);
432            if (config.isEncode()) {
433                value = queryEncode(value);
434            }
435            return of(new AbstractMap.SimpleEntry<>(key, value));
436        }
437    }
438
439    private static class HeadersProvider implements Function<Object[], Map<String, String>> {
440
441        private final Map<Integer, String> headers = new LinkedHashMap<>();
442
443        @Override
444        public Map<String, String> apply(final Object[] args) {
445
446            return headers.entrySet().stream().flatMap(entry -> {
447                if (entry.getValue().isEmpty()) {
448                    return args[entry.getKey()] == null ? empty()
449                            : ((Map<String, String>) args[entry.getKey()]).entrySet().stream();
450                }
451                return ofNullable(args[entry.getKey()])
452                        .map(v -> of(new AbstractMap.SimpleEntry<>(entry.getValue(), String.valueOf(v))))
453                        .orElse(null);
454            })
455                    .filter(Objects::nonNull)
456                    .filter(e -> e.getValue() != null) // ignore null values
457                    .collect(toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> {
458                        throw new IllegalArgumentException("conflictings keys: " + a + '/' + b);
459                    }, LinkedHashMap::new));
460        }
461    }
462
463    @RequiredArgsConstructor
464    private static class PathProvider implements BiFunction<String, Object[], String> {
465
466        private final Map<Integer, Encodable> pathParams = new LinkedHashMap<>();
467
468        /**
469         * @param original : string with placeholders
470         * @param placeholder the placeholder to be replaced
471         * @param value the replacement value
472         * @param encode true if the value need to be encoded
473         * @return a new string with the placeholder replaced by value
474         */
475        private String replacePlaceholder(final String original, final String placeholder, final String value,
476                final boolean encode) {
477            String out = original;
478            int start;
479            do {
480                start = out.indexOf(placeholder);
481                if (start >= 0) {
482                    String replacement = value;
483                    if (encode) {
484                        replacement = pathEncode(replacement);
485                    }
486                    out = out.substring(0, start) + replacement + out.substring(start + placeholder.length());
487                }
488            } while (start >= 0);
489            return out;
490        }
491
492        private static String pathEncode(final String value) {
493            String result = componentEncode(PATH_RESERVED_CHARACTERS, value);
494            // URLEncoder will encode '+' to %2B but will turn ' ' into '+'
495            // We need to retain '+' and encode ' ' as %20
496            if (result.indexOf('+') != -1) {
497                result = result.replace("+", "%20");
498            }
499            if (result.contains("%2B")) {
500                result = result.replace("%2B", "+");
501            }
502            return result;
503        }
504
505        @Override
506        public String apply(final String pathTemplate, final Object[] args) {
507            String path = pathTemplate;
508            if (path == null || path.isEmpty()) {
509                return path;
510            }
511            if (path.startsWith("/")) {
512                path = path.substring(1);
513            }
514            for (final Map.Entry<Integer, Encodable> param : pathParams.entrySet()) {
515                path = replacePlaceholder(path, '{' + param.getValue().name + '}', String.valueOf(args[param.getKey()]),
516                        param.getValue().encode);
517            }
518            return path;
519        }
520    }
521}