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}