All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.micronaut.http.client.interceptor.HttpClientIntroductionAdvice Maven / Gradle / Ivy

The newest version!
/*
 * Copyright 2017-2018 original authors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package io.micronaut.http.client.interceptor;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import io.micronaut.aop.MethodInterceptor;
import io.micronaut.aop.MethodInvocationContext;
import io.micronaut.codec.CodecConfiguration;
import io.micronaut.context.BeanContext;
import io.micronaut.core.annotation.AnnotationMetadata;
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.async.publisher.Publishers;
import io.micronaut.core.async.subscriber.CompletionAwareSubscriber;
import io.micronaut.core.beans.BeanMap;
import io.micronaut.core.convert.ConversionService;
import io.micronaut.core.naming.NameUtils;
import io.micronaut.core.type.Argument;
import io.micronaut.core.type.MutableArgumentValue;
import io.micronaut.core.type.ReturnType;
import io.micronaut.core.util.ArrayUtils;
import io.micronaut.core.util.StringUtils;
import io.micronaut.http.HttpAttributes;
import io.micronaut.http.HttpMethod;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpResponse;
import io.micronaut.http.HttpStatus;
import io.micronaut.http.MediaType;
import io.micronaut.http.MutableHttpRequest;
import io.micronaut.http.annotation.*;
import io.micronaut.http.client.*;
import io.micronaut.http.client.exceptions.HttpClientException;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.http.client.loadbalance.FixedLoadBalancer;
import io.micronaut.http.client.sse.SseClient;
import io.micronaut.http.codec.MediaTypeCodec;
import io.micronaut.http.codec.MediaTypeCodecRegistry;
import io.micronaut.http.netty.cookies.NettyCookie;
import io.micronaut.http.sse.Event;
import io.micronaut.http.uri.UriMatchTemplate;
import io.micronaut.inject.qualifiers.Qualifiers;
import io.micronaut.jackson.ObjectMapperFactory;
import io.micronaut.jackson.annotation.JacksonFeatures;
import io.micronaut.jackson.codec.JsonMediaTypeCodec;
import io.micronaut.runtime.ApplicationConfiguration;
import io.reactivex.Flowable;
import org.reactivestreams.Publisher;
import org.reactivestreams.Subscription;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nullable;
import javax.annotation.PreDestroy;
import javax.inject.Singleton;
import java.io.Closeable;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Annotation;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;

/**
 * Introduction advice that implements the {@link Client} annotation.
 *
 * @author graemerocher
 * @since 1.0
 */
@Singleton
public class HttpClientIntroductionAdvice implements MethodInterceptor, Closeable, AutoCloseable {

    private static final Logger LOG = LoggerFactory.getLogger(DefaultHttpClient.class);

    /**
     * The default Accept-Types.
     */
    private static final MediaType[] DEFAULT_ACCEPT_TYPES = {MediaType.APPLICATION_JSON_TYPE};

    private final int HEADERS_INITIAL_CAPACITY = 3;
    private final BeanContext beanContext;
    private final Map clients = new ConcurrentHashMap<>();
    private final ReactiveClientResultTransformer[] transformers;
    private final LoadBalancerResolver loadBalancerResolver;
    private final JsonMediaTypeCodec jsonMediaTypeCodec;

    /**
     * Constructor for advice class to setup things like Headers, Cookies, Parameters for Clients.
     *
     * @param beanContext          context to resolve beans
     * @param jsonMediaTypeCodec The JSON media type codec
     * @param loadBalancerResolver load balancer resolver
     * @param transformers         transformation classes
     */
    public HttpClientIntroductionAdvice(
        BeanContext beanContext,
        JsonMediaTypeCodec jsonMediaTypeCodec,
        LoadBalancerResolver loadBalancerResolver,
        ReactiveClientResultTransformer... transformers) {

        this.jsonMediaTypeCodec = jsonMediaTypeCodec;
        this.beanContext = beanContext;
        this.loadBalancerResolver = loadBalancerResolver;
        this.transformers = transformers != null ? transformers : new ReactiveClientResultTransformer[0];
    }

    /**
     * Interceptor to apply headers, cookies, parameter and body arguements.
     *
     * @param context The context
     * @return httpClient or future
     */
    @Override
    public Object intercept(MethodInvocationContext context) {
        AnnotationValue clientAnnotation = context.findAnnotation(Client.class).orElseThrow(() ->
                new IllegalStateException("Client advice called from type that is not annotated with @Client: " + context)
        );

        for (MutableArgumentValue argumentValue : context.getParameters().values()) {
            if (argumentValue.getValue() == null && !argumentValue.isAnnotationPresent(Nullable.class)) {
                throw new IllegalArgumentException(
                    String.format("Null values are not allowed to be passed to client methods (%s). Add @javax.validation.Nullable if that is the desired behavior", context.getTargetMethod().toString())
                );
            }
        }

        HttpClient httpClient = getClient(context, clientAnnotation);
        Optional> httpMethodMapping = context.getAnnotationTypeByStereotype(HttpMethodMapping.class);
        if (context.hasStereotype(HttpMethodMapping.class) && httpClient != null) {
            AnnotationValue mapping = context.getAnnotation(HttpMethodMapping.class);
            String uri = mapping.getRequiredValue(String.class);
            if (StringUtils.isEmpty(uri)) {
                uri = "/" + context.getMethodName();
            }

            Class annotationType = httpMethodMapping.get();

            HttpMethod httpMethod = HttpMethod.valueOf(annotationType.getSimpleName().toUpperCase());

            ReturnType returnType = context.getReturnType();
            Class javaReturnType = returnType.getType();

            UriMatchTemplate uriTemplate = UriMatchTemplate.of("");
            if (!(uri.length() == 1 && uri.charAt(0) == '/')) {
                uriTemplate = uriTemplate.nest(uri);
            }

            Map paramMap = context.getParameterValueMap();
            Map queryParams = new LinkedHashMap<>();
            List uriVariables = uriTemplate.getVariables();

            boolean variableSatisfied = uriVariables.isEmpty() || uriVariables.containsAll(paramMap.keySet());
            MutableHttpRequest request;
            Object body = null;
            Map> parameters = context.getParameters();
            Argument[] arguments = context.getArguments();


            Map headers = new LinkedHashMap<>(HEADERS_INITIAL_CAPACITY);

            List> headerAnnotations = context.getAnnotationValuesByType(Header.class);
            for (AnnotationValue
headerAnnotation : headerAnnotations) { String headerName = headerAnnotation.get("name", String.class).orElse(null); String headerValue = headerAnnotation.getValue(String.class).orElse(null); if (StringUtils.isNotEmpty(headerName) && StringUtils.isNotEmpty(headerValue)) { headers.put(headerName, headerValue); } } List cookies = new ArrayList<>(); List bodyArguments = new ArrayList<>(); for (Argument argument : arguments) { String argumentName = argument.getName(); AnnotationMetadata annotationMetadata = argument.getAnnotationMetadata(); if (argument.isAnnotationPresent(Body.class)) { body = parameters.get(argumentName).getValue(); break; } else if (annotationMetadata.isAnnotationPresent(Header.class)) { String headerName = annotationMetadata.getValue(Header.class, String.class).orElse(null); if (StringUtils.isEmpty(headerName)) { headerName = NameUtils.hyphenate(argumentName); } MutableArgumentValue value = parameters.get(argumentName); String finalHeaderName = headerName; ConversionService.SHARED.convert(value.getValue(), String.class) .ifPresent(o -> headers.put(finalHeaderName, o)); } else if (annotationMetadata.isAnnotationPresent(CookieValue.class)) { Object cookieValue = parameters.get(argumentName).getValue(); String cookieName = annotationMetadata.getValue(CookieValue.class, String.class).orElse(null); if (StringUtils.isEmpty(cookieName)) { cookieName = argumentName; } String finalCookieName = cookieName; ConversionService.SHARED.convert(cookieValue, String.class) .ifPresent(o -> cookies.add(new NettyCookie(finalCookieName, o))); } else if (annotationMetadata.isAnnotationPresent(QueryValue.class)) { String parameterName = annotationMetadata.getValue(QueryValue.class, String.class).orElse(null); MutableArgumentValue value = parameters.get(argumentName); ConversionService.SHARED.convert(value.getValue(), String.class).ifPresent(o -> { if (!StringUtils.isEmpty(parameterName)) { paramMap.put(parameterName, o); queryParams.put(parameterName, o); } else { queryParams.put(argumentName, o); } }); } else if (!uriVariables.contains(argumentName)) { bodyArguments.add(argument); } } if (HttpMethod.permitsRequestBody(httpMethod)) { if (body == null && !bodyArguments.isEmpty()) { Map bodyMap = new LinkedHashMap<>(); for (Argument bodyArgument : bodyArguments) { String argumentName = bodyArgument.getName(); MutableArgumentValue value = parameters.get(argumentName); bodyMap.put(argumentName, value.getValue()); } body = bodyMap; } if (body != null) { if (!variableSatisfied) { if (body instanceof Map) { paramMap.putAll((Map) body); } else { BeanMap beanMap = BeanMap.of(body); for (Map.Entry entry : beanMap.entrySet()) { String k = entry.getKey(); Object v = entry.getValue(); if (v != null) { paramMap.put(k, v); } } } } } } uri = uriTemplate.expand(paramMap); uriVariables.forEach(queryParams::remove); request = HttpRequest.create(httpMethod, appendQuery(uri, queryParams)); if (body != null) { request.body(body); } // Set the URI template used to make the request for tracing purposes request.setAttribute(HttpAttributes.URI_TEMPLATE, resolveTemplate(clientAnnotation, uriTemplate.toString())); String serviceId = clientAnnotation.getValue(String.class).orElse(null); Argument errorType = clientAnnotation.get("errorType", Class.class).map((Function) Argument::of).orElse(HttpClient.DEFAULT_ERROR_TYPE); request.setAttribute(HttpAttributes.SERVICE_ID, serviceId); if (!headers.isEmpty()) { for (Map.Entry entry : headers.entrySet()) { request.header(entry.getKey(), entry.getValue()); } } cookies.forEach(request::cookie); boolean isFuture = CompletableFuture.class.isAssignableFrom(javaReturnType); final Class methodDeclaringType = context.getDeclaringType(); if (Publishers.isConvertibleToPublisher(javaReturnType) || isFuture) { boolean isSingle = Publishers.isSingle(javaReturnType) || isFuture || context.getValue(Produces.class, "single", Boolean.class).orElse(false); Argument publisherArgument = returnType.asArgument().getFirstTypeVariable().orElse(Argument.OBJECT_ARGUMENT); Class argumentType = publisherArgument.getType(); if (HttpResponse.class.isAssignableFrom(argumentType) || HttpStatus.class.isAssignableFrom(argumentType)) { isSingle = true; } Publisher publisher; MediaType[] contentTypes = context.getValue(Consumes.class, MediaType[].class).orElse(DEFAULT_ACCEPT_TYPES); if (ArrayUtils.isNotEmpty(contentTypes) && HttpMethod.permitsRequestBody(request.getMethod())) { request.contentType(contentTypes[0]); } if (!isSingle && httpClient instanceof StreamingHttpClient) { StreamingHttpClient streamingHttpClient = (StreamingHttpClient) httpClient; if (HttpResponse.class.isAssignableFrom(argumentType)) { request.accept(context.getValue(Produces.class, MediaType[].class).orElse(DEFAULT_ACCEPT_TYPES)); publisher = streamingHttpClient.exchangeStream( request ); } else if (Void.class.isAssignableFrom(argumentType)) { publisher = streamingHttpClient.exchangeStream( request ); } else { MediaType[] acceptTypes = context.getValue(Produces.class, MediaType[].class).orElse(DEFAULT_ACCEPT_TYPES); request.accept(acceptTypes); boolean isEventStream = Arrays.stream(acceptTypes).anyMatch(mediaType -> mediaType.equals(MediaType.TEXT_EVENT_STREAM_TYPE)); if (isEventStream && streamingHttpClient instanceof SseClient) { SseClient sseClient = (SseClient) streamingHttpClient; if (publisherArgument.getType() == Event.class) { publisher = sseClient.eventStream( request, publisherArgument.getFirstTypeVariable().orElse(Argument.OBJECT_ARGUMENT) ); } else { publisher = Flowable.fromPublisher(sseClient.eventStream( request, publisherArgument )).map(Event::getData); } } else { boolean isJson = isJsonParsedMediaType(acceptTypes); if (isJson) { publisher = streamingHttpClient.jsonStream( request, publisherArgument ); } else { publisher = streamingHttpClient.dataStream( request ); } } } } else { if (HttpResponse.class.isAssignableFrom(argumentType)) { request.accept(context.getValue(Produces.class, MediaType[].class).orElse(DEFAULT_ACCEPT_TYPES)); publisher = httpClient.exchange( request, publisherArgument, errorType ); } else if (Void.class.isAssignableFrom(argumentType)) { publisher = httpClient.exchange( request, null, errorType ); } else { MediaType[] acceptTypes = context.getValue(Produces.class, MediaType[].class).orElse(DEFAULT_ACCEPT_TYPES); request.accept(acceptTypes); publisher = httpClient.retrieve( request, publisherArgument, errorType ); } } if (isFuture) { CompletableFuture future = new CompletableFuture<>(); publisher.subscribe(new CompletionAwareSubscriber() { AtomicReference reference = new AtomicReference<>(); @Override protected void doOnSubscribe(Subscription subscription) { subscription.request(1); } @Override protected void doOnNext(Object message) { if (!Void.class.isAssignableFrom(argumentType)) { reference.set(message); } } @Override protected void doOnError(Throwable t) { if (t instanceof HttpClientResponseException) { HttpClientResponseException e = (HttpClientResponseException) t; if (e.getStatus() == HttpStatus.NOT_FOUND) { future.complete(null); return; } } if (LOG.isErrorEnabled()) { LOG.error("Client [" + methodDeclaringType.getName() + "] received HTTP error response: " + t.getMessage(), t); } future.completeExceptionally(t); } @Override protected void doOnComplete() { future.complete(reference.get()); } }); return future; } else { Object finalPublisher = ConversionService.SHARED.convert(publisher, javaReturnType).orElseThrow(() -> new HttpClientException("Cannot convert response publisher to Reactive type (Unsupported Reactive type): " + javaReturnType) ); for (ReactiveClientResultTransformer transformer : transformers) { finalPublisher = transformer.transform(finalPublisher); } return finalPublisher; } } else { BlockingHttpClient blockingHttpClient = httpClient.toBlocking(); if (HttpResponse.class.isAssignableFrom(javaReturnType)) { return blockingHttpClient.exchange( request, returnType.asArgument().getFirstTypeVariable().orElse(Argument.OBJECT_ARGUMENT), errorType ); } else if (void.class == javaReturnType) { blockingHttpClient.exchange(request, null, errorType); return null; } else { try { return blockingHttpClient.retrieve( request, returnType.asArgument(), errorType ); } catch (RuntimeException t) { if (t instanceof HttpClientResponseException && ((HttpClientResponseException) t).getStatus() == HttpStatus.NOT_FOUND) { if (javaReturnType == Optional.class) { return Optional.empty(); } return null; } else { throw t; } } } } } // try other introduction advice return context.proceed(); } private boolean isJsonParsedMediaType(MediaType[] acceptTypes) { return Arrays.stream(acceptTypes).anyMatch(mediaType -> mediaType.equals(MediaType.APPLICATION_JSON_STREAM_TYPE) || mediaType.getExtension().equals(MediaType.EXTENSION_JSON) || jsonMediaTypeCodec.getMediaTypes().contains(mediaType) ); } /** * Resolve the template for the client annotation. * * @param clientAnnotation client annotation reference * @param templateString template to be applied * @return resolved template contents */ private String resolveTemplate(AnnotationValue clientAnnotation, String templateString) { String path = clientAnnotation.get("path", String.class).orElse(null); if (StringUtils.isNotEmpty(path)) { return path + templateString; } else { String value = clientAnnotation.getValue(String.class).orElse(null); if (StringUtils.isNotEmpty(value)) { if (value.startsWith("/")) { return value + templateString; } } return templateString; } } /** * Gets the client registration for the http request. * * @param context application contextx * @param clientAnn client annotation * @return client registration */ private HttpClient getClient(MethodInvocationContext context, AnnotationValue clientAnn) { String clientId = clientAnn.getValue(String.class).orElse(null); if (StringUtils.isEmpty(clientId)) { return null; } return clients.computeIfAbsent(clientId, integer -> { HttpClient clientBean = beanContext.findBean(HttpClient.class, Qualifiers.byName(clientId)).orElse(null); if (null != clientBean) { return clientBean; } LoadBalancer loadBalancer = loadBalancerResolver.resolve(clientId) .orElseThrow(() -> new HttpClientException("Invalid service reference [" + clientId + "] specified to @Client") ); String contextPath = null; String path = clientAnn.get("path", String.class).orElse(null); if (StringUtils.isNotEmpty(path)) { contextPath = path; } else if (StringUtils.isNotEmpty(clientId) && clientId.startsWith("/")) { contextPath = clientId; } else { if (loadBalancer instanceof FixedLoadBalancer) { contextPath = ((FixedLoadBalancer) loadBalancer).getUrl().getPath(); } } HttpClientConfiguration configuration; Optional clientSpecificConfig = beanContext.findBean( HttpClientConfiguration.class, Qualifiers.byName(clientId) ); Class defaultConfiguration = clientAnn.get("configuration", Class.class).orElse(HttpClientConfiguration.class); configuration = clientSpecificConfig.orElseGet(() -> beanContext.getBean(defaultConfiguration)); HttpClient client = beanContext.createBean(HttpClient.class, loadBalancer, configuration, contextPath); if (client instanceof DefaultHttpClient) { DefaultHttpClient defaultClient = (DefaultHttpClient) client; defaultClient.setClientIdentifiers(clientId); AnnotationValue jacksonFeatures = context.findAnnotation(JacksonFeatures.class).orElse(null); if (jacksonFeatures != null) { Optional existingCodec = defaultClient.getMediaTypeCodecRegistry().findCodec(MediaType.APPLICATION_JSON_TYPE); ObjectMapper objectMapper = null; if (existingCodec.isPresent()) { MediaTypeCodec existing = existingCodec.get(); if (existing instanceof JsonMediaTypeCodec) { objectMapper = ((JsonMediaTypeCodec) existing).getObjectMapper().copy(); } } if (objectMapper == null) { objectMapper = new ObjectMapperFactory().objectMapper(Optional.empty(), Optional.empty()); } SerializationFeature[] enabledSerializationFeatures = jacksonFeatures.get("enabledSerializationFeatures", SerializationFeature[].class).orElse(null); if (enabledSerializationFeatures != null) { for (SerializationFeature serializationFeature : enabledSerializationFeatures) { objectMapper.configure(serializationFeature, true); } } DeserializationFeature[] enabledDeserializationFeatures = jacksonFeatures.get("enabledDeserializationFeatures", DeserializationFeature[].class).orElse(null); if (enabledDeserializationFeatures != null) { for (DeserializationFeature serializationFeature : enabledDeserializationFeatures) { objectMapper.configure(serializationFeature, true); } } SerializationFeature[] disabledSerializationFeatures = jacksonFeatures.get("disabledSerializationFeatures", SerializationFeature[].class).orElse(null); if (disabledSerializationFeatures != null) { for (SerializationFeature serializationFeature : disabledSerializationFeatures) { objectMapper.configure(serializationFeature, false); } } DeserializationFeature[] disabledDeserializationFeatures = jacksonFeatures.get("disabledDeserializationFeatures", DeserializationFeature[].class).orElse(null); if (disabledDeserializationFeatures != null) { for (DeserializationFeature feature : disabledDeserializationFeatures) { objectMapper.configure(feature, false); } } defaultClient.setMediaTypeCodecRegistry( MediaTypeCodecRegistry.of( new JsonMediaTypeCodec(objectMapper, beanContext.getBean(ApplicationConfiguration.class), beanContext.findBean(CodecConfiguration.class, Qualifiers.byName(JsonMediaTypeCodec.CONFIGURATION_QUALIFIER)).orElse(null)))); } } return client; }); } private String appendQuery(String uri, Map queryParams) { if (!queryParams.isEmpty()) { try { URI oldUri = new URI(uri); StringBuilder sb = new StringBuilder(oldUri.getQuery() == null ? "" : oldUri.getQuery()); if (sb.length() > 0) { sb.append('&'); } for (Map.Entry entry: queryParams.entrySet()) { sb.append(URLEncoder.encode(entry.getKey(), "UTF-8")); sb.append('='); sb.append(URLEncoder.encode(entry.getValue(), "UTF-8")); } return new URI(oldUri.getScheme(), oldUri.getAuthority(), oldUri.getPath(), sb.toString(), oldUri.getFragment()).toString(); } catch (URISyntaxException | UnsupportedEncodingException e) { //no-op } } return uri; } /** * Cleanup method to prevent resource leaking. * */ @Override @PreDestroy public void close() { for (HttpClient client : clients.values()) { client.close(); } } }