diff --git a/core/src/main/java/feign/RequestTemplateFactoryResolver.java b/core/src/main/java/feign/RequestTemplateFactoryResolver.java index e39160b18..0d64fd743 100644 --- a/core/src/main/java/feign/RequestTemplateFactoryResolver.java +++ b/core/src/main/java/feign/RequestTemplateFactoryResolver.java @@ -47,7 +47,7 @@ public RequestTemplate.Factory resolve(Target target, MethodMetadata md) { } } - private static class BuildTemplateByResolvingArgs implements RequestTemplate.Factory { + static class BuildTemplateByResolvingArgs implements RequestTemplate.Factory { private final QueryMapEncoder queryMapEncoder; @@ -56,7 +56,7 @@ private static class BuildTemplateByResolvingArgs implements RequestTemplate.Fac private final Map indexToExpander = new LinkedHashMap(); - private BuildTemplateByResolvingArgs( + BuildTemplateByResolvingArgs( MethodMetadata metadata, QueryMapEncoder queryMapEncoder, Target target) { this.metadata = metadata; this.target = target; @@ -212,11 +212,11 @@ protected RequestTemplate resolve( } } - private static class BuildFormEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs { + static class BuildFormEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs { private final Encoder encoder; - private BuildFormEncodedTemplateFromArgs( + BuildFormEncodedTemplateFromArgs( MethodMetadata metadata, Encoder encoder, QueryMapEncoder queryMapEncoder, Target target) { super(metadata, queryMapEncoder, target); this.encoder = encoder; @@ -242,11 +242,11 @@ protected RequestTemplate resolve( } } - private static class BuildEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs { + static class BuildEncodedTemplateFromArgs extends BuildTemplateByResolvingArgs { private final Encoder encoder; - private BuildEncodedTemplateFromArgs( + BuildEncodedTemplateFromArgs( MethodMetadata metadata, Encoder encoder, QueryMapEncoder queryMapEncoder, Target target) { super(metadata, queryMapEncoder, target); this.encoder = encoder; diff --git a/pom.xml b/pom.xml index 2574aa33f..3c970a220 100644 --- a/pom.xml +++ b/pom.xml @@ -76,6 +76,10 @@ Guillaume Simard + + Alexei KLENIN + alexei.klenin@gmail.com + @@ -122,6 +126,7 @@ fastjson2 feign-form feign-form-spring + vertx diff --git a/vertx/README.md b/vertx/README.md new file mode 100644 index 000000000..b077fe001 --- /dev/null +++ b/vertx/README.md @@ -0,0 +1,78 @@ +# Feign Vertx + +Implementation of Feign on Vertx. Brings you the best of two worlds together : +concise syntax of Feign to write client side API on fast, asynchronous and +non-blocking HTTP client of Vertx. + +## Installation + +### With Maven + +```xml + + ... + + io.github.openfeign + feign-vertx + 14.0 + + ... + +``` + +### With Gradle + +```groovy +compile group: 'io.github.openfeign', name: 'feign-vertx', version: '14.0' +``` + +## Compatibility + +Feign | Vertx +---------------------- | ---------------------- +14.x | 4.x + +## Usage + +Write Feign API as usual, but every method of interface must return +`io.vertx.core.Future`. + +```java +@Headers({ "Accept: application/json" }) +interface IcecreamServiceApi { + + @RequestLine("GET /icecream/flavors") + Future> getAvailableFlavors(); + + @RequestLine("GET /icecream/mixins") + Future> getAvailableMixins(); + + @RequestLine("POST /icecream/orders") + @Headers("Content-Type: application/json") + Future makeOrder(IceCreamOrder order); + + @RequestLine("GET /icecream/orders/{orderId}") + Future findOrder(@Param("orderId") int orderId); + + @RequestLine("POST /icecream/bills/pay") + @Headers("Content-Type: application/json") + Future payBill(Bill bill); +} +``` +Build the client : + +```java +Vertx vertx = Vertx.vertx(); // get Vertx instance + +/* Create instance of your API */ +IcecreamServiceApi icecreamApi = VertxFeign + .builder() + .vertx(vertx) // provide vertx instance + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .target(IcecreamServiceApi.class, "http://www.icecreame.com"); + +/* Execute requests asynchronously */ +Future> flavorsFuture = icecreamApi.getAvailableFlavors(); +Future> mixinsFuture = icecreamApi.getAvailableMixins(); +``` diff --git a/vertx/pom.xml b/vertx/pom.xml new file mode 100644 index 000000000..22985f296 --- /dev/null +++ b/vertx/pom.xml @@ -0,0 +1,123 @@ + + + + 4.0.0 + + io.github.openfeign + parent + 13.6-SNAPSHOT + + + feign-vertx + + Feign Vertx + Implementation of Feign on Vertx web client. + + + 4.5.10 + 2.12.0 + 1.8.0-beta0 + 2.35.1 + + + + + + com.fasterxml.jackson + jackson-bom + ${jackson.version} + pom + import + + + + + + + io.github.openfeign + feign-core + + + + + io.vertx + vertx-core + ${vertx.version} + provided + + + + + io.vertx + vertx-junit5 + ${vertx.version} + test + + + + org.assertj + assertj-core + test + + + + io.github.openfeign + feign-jackson + test + + + + com.fasterxml.jackson.core + jackson-annotations + test + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + test + + + + io.github.openfeign + feign-slf4j + test + + + + org.slf4j + slf4j-log4j12 + ${slf4j-log4j12.version} + test + + + + com.github.tomakehurst + wiremock-jre8 + ${wiremock.version} + test + + + org.junit + junit-bom + + + + + diff --git a/vertx/run-tests.zsh b/vertx/run-tests.zsh new file mode 100755 index 000000000..a0be64ca4 --- /dev/null +++ b/vertx/run-tests.zsh @@ -0,0 +1,53 @@ +#!/usr/bin/env zsh + +RED='\033[0;31m' +GREEN='\033[0;32m' +NC='\033[0m' + +CHECK_CHAR='\U2713' +CROSS_CHAR='\U2717' + +function print_result() { + version=$1 + result=$2 + + if [[ $result == 0 ]]; + then + mark=$CHECK_CHAR + color=$GREEN + else + mark=$CROSS_CHAR + color=$RED + fi + + echo "\t${color}${version} ${mark}${NC}" +} + +feign_versions=( "12.5" "13.5" ) + +for feign_version in $feign_versions; do + echo "Tests with Feign ${version}:" + + printf "\tRun tests with Feign %s...\n" "${feign_version}" + mvn clean compile test -Dfeign.version="$feign_version" &> /dev/null + print_result "$feign_version" $? +done + +declare -A vertx_versions +vertx_versions=( [v40x]="4.0.x", [v41x]="4.1.x", [v42x]="4.2.x", [v43x]="4.3.x", [v44x]="4.4.x", [v45x]="4.5.x" ) +v40x=( "4.0.2" ) +v41x=( "4.1.8" ) +v42x=( "4.2.7" ) +v43x=( "4.3.2" ) +v44x=( "4.4.9" ) +v45x=( "4.5.10" ) + +for version in ${(k)vertx_versions}; do + echo "Tests with Vertx ${vertx_versions[${version}]}:" + + for vertx_version in ${(P)version}; do + printf "\tRun tests with Vertx %s...\n" "${vertx_version}" + mvn clean compile test -Dvertx.version="$vertx_version" &> /dev/null + print_result "$vertx_version" $? + done +done diff --git a/vertx/src/main/java/feign/VertxFeign.java b/vertx/src/main/java/feign/VertxFeign.java new file mode 100644 index 000000000..df3e8706a --- /dev/null +++ b/vertx/src/main/java/feign/VertxFeign.java @@ -0,0 +1,476 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign; + +import static feign.Util.checkNotNull; +import static feign.Util.isDefault; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.codec.Decoder; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import feign.querymap.FieldQueryMapEncoder; +import feign.vertx.VertxDelegatingContract; +import feign.vertx.VertxHttpClient; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClient; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpClientRequest; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.UnaryOperator; + +/** + * Allows Feign interfaces to return Vert.x {@link io.vertx.core.Future Future}s. + * + * @author Alexei KLENIN + * @author Gordon McKinney + */ +public final class VertxFeign extends Feign { + private final ParseHandlersByName targetToHandlersByName; + private final InvocationHandlerFactory factory; + + private VertxFeign( + final ParseHandlersByName targetToHandlersByName, final InvocationHandlerFactory factory) { + this.targetToHandlersByName = targetToHandlersByName; + this.factory = factory; + } + + public static Builder builder() { + return new Builder(); + } + + @Override + @SuppressWarnings("unchecked") + public T newInstance(final Target target) { + checkNotNull(target, "Argument target must be not null"); + + final Map nameToHandler = targetToHandlersByName.apply(target); + final Map methodToHandler = new HashMap<>(); + final List defaultMethodHandlers = new ArrayList<>(); + + for (final Method method : target.type().getMethods()) { + if (isDefault(method)) { + final DefaultMethodHandler handler = new DefaultMethodHandler(method); + defaultMethodHandlers.add(handler); + methodToHandler.put(method, handler); + } else { + methodToHandler.put(method, nameToHandler.get(Feign.configKey(target.type(), method))); + } + } + + final InvocationHandler handler = factory.create(target, methodToHandler); + final T proxy = + (T) + Proxy.newProxyInstance( + target.type().getClassLoader(), new Class[] {target.type()}, handler); + + for (final DefaultMethodHandler defaultMethodHandler : defaultMethodHandlers) { + defaultMethodHandler.bindTo(proxy); + } + + return proxy; + } + + /** VertxFeign builder. */ + public static final class Builder extends Feign.Builder { + private Vertx vertx; + private final List requestInterceptors = new ArrayList<>(); + private Logger.Level logLevel = Logger.Level.NONE; + private Contract contract = new VertxDelegatingContract(new Contract.Default()); + private Retryer retryer = new Retryer.Default(); + private Logger logger = new Logger.NoOpLogger(); + private Encoder encoder = new Encoder.Default(); + private Decoder decoder = new Decoder.Default(); + private QueryMapEncoder queryMapEncoder = new FieldQueryMapEncoder(); + private List capabilities = new ArrayList<>(); + private ErrorDecoder errorDecoder = new ErrorDecoder.Default(); + private HttpClientOptions options = new HttpClientOptions(); + private long timeout = -1; + private boolean decode404; + private UnaryOperator requestPreProcessor = UnaryOperator.identity(); + + /** Unsupported operation. */ + @Override + public Builder client(final Client client) { + throw new UnsupportedOperationException(); + } + + /** Unsupported operation. */ + @Override + public Builder invocationHandlerFactory( + final InvocationHandlerFactory invocationHandlerFactory) { + throw new UnsupportedOperationException(); + } + + /** + * Sets a vertx instance to use to make the client. + * + * @param vertx vertx instance + * @return this builder + */ + public Builder vertx(final Vertx vertx) { + this.vertx = checkNotNull(vertx, "Argument vertx must be not null"); + return this; + } + + /** + * Sets log level. + * + * @param logLevel log level + * @return this builder + */ + @Override + public Builder logLevel(final Logger.Level logLevel) { + this.logLevel = checkNotNull(logLevel, "Argument logLevel must be not null"); + return this; + } + + /** + * Sets contract. Provided contract will be wrapped in {@link VertxDelegatingContract}. + * + * @param contract contract + * @return this builder + */ + @Override + public Builder contract(final Contract contract) { + checkNotNull(contract, "Argument contract must be not null"); + this.contract = new VertxDelegatingContract(contract); + return this; + } + + /** + * Sets retryer. + * + * @param retryer retryer + * @return this builder + */ + @Override + public Builder retryer(final Retryer retryer) { + this.retryer = checkNotNull(retryer, "Argument retryer must be not null"); + return this; + } + + /** + * Sets logger. + * + * @param logger logger + * @return this builder + */ + @Override + public Builder logger(final Logger logger) { + this.logger = checkNotNull(logger, "Argument logger must be not null"); + return this; + } + + /** + * Sets encoder. + * + * @param encoder encoder + * @return this builder + */ + @Override + public Builder encoder(final Encoder encoder) { + this.encoder = checkNotNull(encoder, "Argument encoder must be not null"); + return this; + } + + /** + * Sets decoder. + * + * @param decoder decoder + * @return this builder + */ + @Override + public Builder decoder(final Decoder decoder) { + this.decoder = checkNotNull(decoder, "Argument decoder must be not null"); + return this; + } + + /** + * Sets query map encoder. + * + * @param queryMapEncoder query map encoder + * @return this builder + */ + @Override + public Builder queryMapEncoder(final QueryMapEncoder queryMapEncoder) { + this.queryMapEncoder = + checkNotNull(queryMapEncoder, "Argument queryMapEncoder must be not null"); + return this; + } + + /** + * Adds a single capability to the builder. + * + * @param capability capability + * @return this builder + */ + @Override + public Builder addCapability(Capability capability) { + checkNotNull(capability, "Argument capability must be not null"); + this.capabilities.add(capability); + return this; + } + + /** + * This flag indicates that the {@link #decoder(Decoder) decoder} should process responses with + * 404 status, specifically returning null or empty instead of throwing {@link FeignException}. + * + *

All first-party (ex gson) decoders return well-known empty values defined by {@link + * Util#emptyValueOf}. To customize further, wrap an existing {@link #decoder(Decoder) decoder} + * or make your own. + * + *

This flag only works with 404, as opposed to all or arbitrary status codes. This was an + * explicit decision: 404 - empty is safe, common and doesn't complicate redirection, retry or + * fallback policy. + * + * @return this builder + */ + @Override + public Builder decode404() { + this.decode404 = true; + return this; + } + + /** + * Sets error decoder. + * + * @param errorDecoder error deoceder + * @return this builder + */ + @Override + public Builder errorDecoder(final ErrorDecoder errorDecoder) { + this.errorDecoder = checkNotNull(errorDecoder, "Argument errorDecoder must be not null"); + return this; + } + + /** + * Sets request options using Vert.x {@link HttpClientOptions}. + * + * @param options {@code HttpClientOptions} for full customization of the underlying Vert.x + * {@link HttpClient} + * @return this builder + */ + public Builder options(final HttpClientOptions options) { + this.options = checkNotNull(options, "Argument options must be not null"); + return this; + } + + /** + * Sets request options using Feign {@link Request.Options}. + * + * @param options Feign {@code Request.Options} object + * @return this builder + */ + @Override + public Builder options(final Request.Options options) { + checkNotNull(options, "Argument options must be not null"); + this.options = + new HttpClientOptions() + .setConnectTimeout(options.connectTimeoutMillis()) + .setIdleTimeout(options.readTimeoutMillis()); + return this; + } + + /** + * Configures the amount of time in milliseconds after which if the request does not return any + * data within the timeout period an {@link java.util.concurrent.TimeoutException} fails the + * request. + * + *

Setting zero or a negative {@code value} disables the timeout. + * + * @param timeout The quantity of time in milliseconds. + * @return this builder + */ + public Builder timeout(long timeout) { + this.timeout = timeout; + return this; + } + + /** + * Defines operation to execute on each {@link HttpClientRequest} before it is sent. Used to + * make setup on request level. + * + *

Example: + * + *

+     * var client = VertxFeign
+     *     .builder()
+     *     .vertx(vertx)
+     *     .requestPreProcessor(req -> req.putHeader("version", "v1"));
+     * 
+ * + * @param requestPreProcessor operation to execute on each request + * @return updated request + */ + public Builder requestPreProcessor(UnaryOperator requestPreProcessor) { + this.requestPreProcessor = + checkNotNull(requestPreProcessor, "Argument requestPreProcessor must be not null"); + return this; + } + + /** + * Adds a single request interceptor to the builder. + * + * @param requestInterceptor request interceptor to add + * @return this builder + */ + @Override + public Builder requestInterceptor(final RequestInterceptor requestInterceptor) { + checkNotNull(requestInterceptor, "Argument requestInterceptor must be not null"); + this.requestInterceptors.add(requestInterceptor); + return this; + } + + /** + * Sets the full set of request interceptors for the builder, overwriting any previous + * interceptors. + * + * @param requestInterceptors set of request interceptors + * @return this builder + */ + @Override + public Builder requestInterceptors(final Iterable requestInterceptors) { + checkNotNull(requestInterceptors, "Argument requestInterceptors must be not null"); + + this.requestInterceptors.clear(); + + for (final RequestInterceptor requestInterceptor : requestInterceptors) { + this.requestInterceptors.add(requestInterceptor); + } + + return this; + } + + /** + * Defines target and builds client. + * + * @param apiType API interface + * @param url base URL + * @param class of API interface + * @return built client + */ + @Override + public T target(final Class apiType, final String url) { + checkNotNull(apiType, "Argument apiType must be not null"); + checkNotNull(url, "Argument url must be not null"); + + return target(new Target.HardCodedTarget<>(apiType, url)); + } + + /** + * Defines target and builds client. + * + * @param target target instance + * @param class of API interface + * @return built client + */ + @Override + public T target(final Target target) { + return build().newInstance(target); + } + + @Override + public VertxFeign internalBuild() { + checkNotNull(this.vertx, "Vertx instance wasn't provided in VertxFeign builder"); + + final VertxHttpClient client = + new VertxHttpClient(vertx, options, timeout, requestPreProcessor); + final VertxMethodHandler.Factory methodHandlerFactory = + new VertxMethodHandler.Factory( + client, retryer, requestInterceptors, logger, logLevel, decode404); + final ParseHandlersByName handlersByName = + new ParseHandlersByName( + contract, + options, + encoder, + decoder, + queryMapEncoder, + capabilities, + errorDecoder, + methodHandlerFactory); + final InvocationHandlerFactory invocationHandlerFactory = + new VertxInvocationHandler.Factory(); + + return new VertxFeign(handlersByName, invocationHandlerFactory); + } + } + + private static final class ParseHandlersByName { + private final Contract contract; + private final HttpClientOptions options; + private final Encoder encoder; + private final Decoder decoder; + private final QueryMapEncoder queryMapEncoder; + private final List capabilities; + private final ErrorDecoder errorDecoder; + private final VertxMethodHandler.Factory factory; + + private ParseHandlersByName( + final Contract contract, + final HttpClientOptions options, + final Encoder encoder, + final Decoder decoder, + final QueryMapEncoder queryMapEncoder, + final List capabilities, + final ErrorDecoder errorDecoder, + final VertxMethodHandler.Factory factory) { + this.contract = contract; + this.options = options; + this.factory = factory; + this.encoder = encoder; + this.decoder = decoder; + this.queryMapEncoder = queryMapEncoder; + this.capabilities = capabilities; + this.errorDecoder = errorDecoder; + } + + private Map apply(final Target target) { + final List metadata = contract.parseAndValidateMetadata(target.type()); + final Map result = new HashMap<>(); + + for (final MethodMetadata metadatum : metadata) { + RequestTemplateFactoryResolver.BuildTemplateByResolvingArgs buildTemplate; + + if (!metadatum.formParams().isEmpty() && metadatum.template().bodyTemplate() == null) { + buildTemplate = + new RequestTemplateFactoryResolver.BuildFormEncodedTemplateFromArgs( + metadatum, encoder, queryMapEncoder, target); + } else if (metadatum.bodyIndex() != null || metadatum.alwaysEncodeBody()) { + buildTemplate = + new RequestTemplateFactoryResolver.BuildEncodedTemplateFromArgs( + metadatum, encoder, queryMapEncoder, target); + } else { + buildTemplate = + new RequestTemplateFactoryResolver.BuildTemplateByResolvingArgs( + metadatum, queryMapEncoder, target); + } + + result.put( + metadatum.configKey(), + factory.create(target, metadatum, buildTemplate, decoder, errorDecoder)); + } + + return result; + } + } +} diff --git a/vertx/src/main/java/feign/VertxInvocationHandler.java b/vertx/src/main/java/feign/VertxInvocationHandler.java new file mode 100644 index 000000000..1611c491e --- /dev/null +++ b/vertx/src/main/java/feign/VertxInvocationHandler.java @@ -0,0 +1,120 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.vertx.VertxHttpClient; +import io.vertx.core.Future; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Map; + +/** + * {@link InvocationHandler} implementation that transforms calls to methods of feign contract into + * asynchronous HTTP requests via vertx. + * + * @author Alexei KLENIN + */ +final class VertxInvocationHandler implements InvocationHandler { + private final Target target; + private final Map dispatch; + + private VertxInvocationHandler( + final Target target, final Map dispatch) { + this.target = target; + this.dispatch = dispatch; + } + + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) { + switch (method.getName()) { + case "equals": + final Object otherHandler = + args.length > 0 && args[0] != null ? Proxy.getInvocationHandler(args[0]) : null; + return equals(otherHandler); + case "hashCode": + return hashCode(); + case "toString": + return toString(); + default: + if (isReturnsFuture(method)) { + return invokeRequestMethod(method, args); + } else { + final String message = + String.format( + "Method %s of contract %s doesn't return io.vertx.core.Future", + method.getName(), method.getDeclaringClass().getSimpleName()); + throw new FeignException(-1, message); + } + } + } + + /** + * Transforms method invocation into request that executed by {@link VertxHttpClient}. + * + * @param method invoked method + * @param args provided arguments to method + * @return future with decoded result or occurred exception + */ + private Future invokeRequestMethod(final Method method, final Object[] args) { + try { + return (Future) dispatch.get(method).invoke(args); + } catch (Throwable throwable) { + return Future.failedFuture(throwable); + } + } + + /** + * Checks if method must return vertx {@code Future}. + * + * @param method invoked method + * @return true if method must return Future, false if not + */ + private boolean isReturnsFuture(final Method method) { + return Future.class.isAssignableFrom(method.getReturnType()); + } + + @Override + public boolean equals(final Object other) { + if (other instanceof VertxInvocationHandler) { + final VertxInvocationHandler otherHandler = (VertxInvocationHandler) other; + return this.target.equals(otherHandler.target); + } + + return false; + } + + @Override + public int hashCode() { + return target.hashCode(); + } + + @Override + public String toString() { + return target.toString(); + } + + /** Factory for VertxInvocationHandler. */ + static final class Factory implements InvocationHandlerFactory { + + @Override + public InvocationHandler create( + final Target target, final Map dispatch) { + return new VertxInvocationHandler(target, dispatch); + } + } +} diff --git a/vertx/src/main/java/feign/VertxMethodHandler.java b/vertx/src/main/java/feign/VertxMethodHandler.java new file mode 100644 index 000000000..1e9975cf4 --- /dev/null +++ b/vertx/src/main/java/feign/VertxMethodHandler.java @@ -0,0 +1,314 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign; + +import static feign.FeignException.errorExecuting; +import static feign.FeignException.errorReading; +import static feign.Util.ensureClosed; + +import feign.InvocationHandlerFactory.MethodHandler; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.codec.ErrorDecoder; +import feign.vertx.VertxHttpClient; +import io.vertx.core.Future; +import io.vertx.core.VertxException; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; + +/** + * Method handler for asynchronous HTTP requests via {@link VertxHttpClient}. Inspired by {@link + * SynchronousMethodHandler}. + * + * @author Alexei KLENIN + * @author Gordon McKinney + */ +final class VertxMethodHandler implements MethodHandler { + private static final long MAX_RESPONSE_BUFFER_SIZE = 8192L; + + private final MethodMetadata metadata; + private final Target target; + private final VertxHttpClient client; + private final Retryer retryer; + private final List requestInterceptors; + private final Logger logger; + private final Logger.Level logLevel; + private final RequestTemplate.Factory buildTemplateFromArgs; + private final Decoder decoder; + private final ErrorDecoder errorDecoder; + private final boolean decode404; + + private VertxMethodHandler( + final Target target, + final VertxHttpClient client, + final Retryer retryer, + final List requestInterceptors, + final Logger logger, + final Logger.Level logLevel, + final MethodMetadata metadata, + final RequestTemplate.Factory buildTemplateFromArgs, + final Decoder decoder, + final ErrorDecoder errorDecoder, + final boolean decode404) { + this.target = target; + this.client = client; + this.retryer = retryer; + this.requestInterceptors = requestInterceptors; + this.logger = logger; + this.logLevel = logLevel; + this.metadata = metadata; + this.buildTemplateFromArgs = buildTemplateFromArgs; + this.errorDecoder = errorDecoder; + this.decoder = decoder; + this.decode404 = decode404; + } + + @Override + @SuppressWarnings("unchecked") + public Future invoke(final Object[] argv) { + final RequestTemplate template = buildTemplateFromArgs.create(argv); + final Retryer retryer = this.retryer.clone(); + + final RetryRecoverer recoverer = new RetryRecoverer<>(template, retryer); + return executeAndDecode(template).recover(recoverer); + } + + /** + * Executes request from {@code template} with {@code this.client} and decodes the response. + * Result or occurred error wrapped in returned Future. + * + * @param template request template + * @return future with decoded result or occurred error + */ + private Future executeAndDecode(final RequestTemplate template) { + final Request request = targetRequest(template); + + logRequest(request); + + final Instant start = Instant.now(); + + return client + .execute(request) + .compose( + response -> { + final long elapsedTime = Duration.between(start, Instant.now()).toMillis(); + boolean shouldClose = true; + + try { + // TODO: check why this buffering is needed + if (logLevel != Logger.Level.NONE) { + response = + logger.logAndRebufferResponse( + metadata.configKey(), logLevel, response, elapsedTime); + } + + if (Response.class == metadata.returnType()) { + if (response.body() == null) { + return Future.succeededFuture(response); + } else if (response.body().length() == null + || response.body().length() > MAX_RESPONSE_BUFFER_SIZE) { + shouldClose = false; + return Future.succeededFuture(response); + } else { + return Future.succeededFuture( + Response.builder() + .status(response.status()) + .reason(response.reason()) + .headers(response.headers()) + .request(response.request()) + .body(response.body()) + .build()); + } + } else if (response.status() >= 200 && response.status() < 300) { + if (Void.class == metadata.returnType()) { + return Future.succeededFuture(); + } else { + return Future.succeededFuture(decode(response, request)); + } + } else if (decode404 && response.status() == 404) { + return Future.succeededFuture(decoder.decode(response, metadata.returnType())); + } else { + return Future.failedFuture(errorDecoder.decode(metadata.configKey(), response)); + } + } catch (final IOException ioException) { + logIoException(ioException, elapsedTime); + return Future.failedFuture(errorReading(request, response, ioException)); + } catch (FeignException exception) { + return Future.failedFuture(exception); + } finally { + if (shouldClose) { + ensureClosed(response.body()); + } + } + }, + failure -> { + if (failure instanceof VertxException || failure instanceof TimeoutException) { + return Future.failedFuture(failure); + } else if (failure.getCause() instanceof IOException) { + final long elapsedTime = Duration.between(start, Instant.now()).toMillis(); + logIoException((IOException) failure.getCause(), elapsedTime); + return Future.failedFuture( + errorExecuting(request, (IOException) failure.getCause())); + } else { + return Future.failedFuture(failure.getCause()); + } + }); + } + + /** + * Associates request to defined target. + * + * @param template request template + * @return fully formed request + */ + private Request targetRequest(final RequestTemplate template) { + for (final RequestInterceptor interceptor : requestInterceptors) { + interceptor.apply(template); + } + + return target.apply(template); + } + + /** + * Transforms HTTP response body into object using decoder. + * + * @param response HTTP response + * @param request HTTP request + * @return decoded result + * @throws IOException IO exception during the reading of InputStream of response + * @throws DecodeException when decoding failed due to a checked or unchecked exception besides + * IOException + * @throws FeignException when decoding succeeds, but conveys the operation failed + */ + private Object decode(final Response response, final Request request) + throws IOException, FeignException { + try { + return decoder.decode(response, metadata.returnType()); + } catch (final FeignException feignException) { + /* All feign exception including decode exceptions */ + throw feignException; + } catch (final RuntimeException unexpectedException) { + /* Any unexpected exception */ + throw new DecodeException(-1, unexpectedException.getMessage(), request, unexpectedException); + } + } + + /** + * Logs request. + * + * @param request HTTP request + */ + private void logRequest(final Request request) { + if (logLevel != Logger.Level.NONE) { + logger.logRequest(metadata.configKey(), logLevel, request); + } + } + + /** + * Logs IO exception. + * + * @param exception IO exception + * @param elapsedTime time spent to execute request + */ + private void logIoException(final IOException exception, final long elapsedTime) { + if (logLevel != Logger.Level.NONE) { + logger.logIOException(metadata.configKey(), logLevel, exception, elapsedTime); + } + } + + /** Logs retry. */ + private void logRetry() { + if (logLevel != Logger.Level.NONE) { + logger.logRetry(metadata.configKey(), logLevel); + } + } + + static final class Factory { + private final VertxHttpClient client; + private final Retryer retryer; + private final List requestInterceptors; + private final Logger logger; + private final Logger.Level logLevel; + private final boolean decode404; + + Factory( + final VertxHttpClient client, + final Retryer retryer, + final List requestInterceptors, + final Logger logger, + final Logger.Level logLevel, + final boolean decode404) { + this.client = client; + this.retryer = retryer; + this.requestInterceptors = requestInterceptors; + this.logger = logger; + this.logLevel = logLevel; + this.decode404 = decode404; + } + + MethodHandler create( + final Target target, + final MethodMetadata metadata, + final RequestTemplate.Factory buildTemplateFromArgs, + final Decoder decoder, + final ErrorDecoder errorDecoder) { + return new VertxMethodHandler( + target, + client, + retryer, + requestInterceptors, + logger, + logLevel, + metadata, + buildTemplateFromArgs, + decoder, + errorDecoder, + decode404); + } + } + + /** + * Handler for failures able to retry execution of request. In this case handler passed to new + * request. + * + * @param type of response + */ + private final class RetryRecoverer implements Function> { + private final RequestTemplate template; + private final Retryer retryer; + + private RetryRecoverer(final RequestTemplate template, final Retryer retryer) { + this.template = template; + this.retryer = retryer; + } + + @Override + @SuppressWarnings("unchecked") + public Future apply(final Throwable throwable) { + if (throwable instanceof RetryableException) { + this.retryer.continueOrPropagate((RetryableException) throwable); + logRetry(); + return ((Future) executeAndDecode(this.template)).recover(this); + } else { + return Future.failedFuture(throwable); + } + } + } +} diff --git a/vertx/src/main/java/feign/package-info.java b/vertx/src/main/java/feign/package-info.java new file mode 100644 index 000000000..77ef0ae74 --- /dev/null +++ b/vertx/src/main/java/feign/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 for extensions that must be able use package-private classes of {@code feign}. + * + * @author Alexei KLENIN + */ +package feign; diff --git a/vertx/src/main/java/feign/vertx/VertxDelegatingContract.java b/vertx/src/main/java/feign/vertx/VertxDelegatingContract.java new file mode 100644 index 000000000..07799acdb --- /dev/null +++ b/vertx/src/main/java/feign/vertx/VertxDelegatingContract.java @@ -0,0 +1,63 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static feign.Types.resolveLastTypeParameter; +import static feign.Util.checkNotNull; + +import feign.Contract; +import feign.MethodMetadata; +import io.vertx.core.Future; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.List; + +/** + * Contract allowing only {@link Future} return type. + * + * @author Alexei KLENIN + */ +public final class VertxDelegatingContract implements Contract { + private final Contract delegate; + + public VertxDelegatingContract(final Contract delegate) { + this.delegate = checkNotNull(delegate, "delegate must not be null"); + } + + @Override + public List parseAndValidateMetadata(final Class targetType) { + checkNotNull(targetType, "Argument targetType must be not null"); + + final List metadatas = delegate.parseAndValidateMetadata(targetType); + + for (final MethodMetadata metadata : metadatas) { + final Type type = metadata.returnType(); + + if (type instanceof ParameterizedType + && ((ParameterizedType) type).getRawType().equals(Future.class)) { + final Type actualType = resolveLastTypeParameter(type, Future.class); + metadata.returnType(actualType); + } else { + throw new IllegalStateException( + String.format( + "Method %s of contract %s doesn't returns io.vertx.core.Future", + metadata.configKey(), targetType.getSimpleName())); + } + } + + return metadatas; + } +} diff --git a/vertx/src/main/java/feign/vertx/VertxHttpClient.java b/vertx/src/main/java/feign/vertx/VertxHttpClient.java new file mode 100644 index 000000000..5e545c6d8 --- /dev/null +++ b/vertx/src/main/java/feign/vertx/VertxHttpClient.java @@ -0,0 +1,148 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static feign.Util.checkNotNull; + +import feign.Request; +import feign.Response; +import io.vertx.core.Future; +import io.vertx.core.MultiMap; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.http.*; +import io.vertx.core.http.impl.headers.HeadersMultiMap; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +/** + * Like {@link feign.Client} but method {@link #execute} returns {@link Future} with {@link + * Response}. HTTP request is executed asynchronously with Vert.x + * + * @author Alexei KLENIN + * @author Gordon McKinney + */ +@SuppressWarnings("unused") +public final class VertxHttpClient { + private final HttpClient httpClient; + private final long timeout; + private final UnaryOperator requestPreProcessor; + + /** + * Constructor from {@link Vertx} instance, HTTP client options and request timeout. + * + * @param vertx vertx instance + * @param options HTTP options + * @param timeout request timeout + * @param requestPreProcessor request pre-processor + */ + public VertxHttpClient( + final Vertx vertx, + final HttpClientOptions options, + final long timeout, + final UnaryOperator requestPreProcessor) { + checkNotNull(vertx, "Argument vertx must not be null"); + checkNotNull(options, "Argument options must be not null"); + checkNotNull(requestPreProcessor, "Argument requestPreProcessor must be not null"); + + this.httpClient = vertx.createHttpClient(options); + this.timeout = timeout; + this.requestPreProcessor = requestPreProcessor; + } + + /** + * Executes HTTP request and returns {@link Future} with response. + * + * @param request request + * @return future of HTTP response + */ + public Future execute(final Request request) { + checkNotNull(request, "Argument request must be not null"); + + final Future httpClientRequest; + + try { + httpClientRequest = makeHttpClientRequest(request); + } catch (final MalformedURLException unexpectedException) { + return Future.failedFuture(unexpectedException); + } + + final Future responseFuture = + httpClientRequest.compose( + req -> request.body() != null ? req.send(Buffer.buffer(request.body())) : req.send()); + + return responseFuture.compose( + response -> { + final Map> responseHeaders = + StreamSupport.stream(response.headers().spliterator(), false) + .collect( + Collectors.groupingBy( + Map.Entry::getKey, + Collectors.mapping( + Map.Entry::getValue, Collectors.toCollection(ArrayList::new)))); + + return response + .body() + .map( + body -> + Response.builder() + .status(response.statusCode()) + .reason(response.statusMessage()) + .headers(responseHeaders) + .body(body.getBytes()) + .request(request) + .build()); + }); + } + + private Future makeHttpClientRequest(final Request request) + throws MalformedURLException { + final URL url = new URL(request.url()); + final String host = url.getHost(); + final String requestUri = url.getFile(); + + int port; + if (url.getPort() > -1) { + port = url.getPort(); + } else if (url.getProtocol().equalsIgnoreCase("https")) { + port = 443; + } else { + port = HttpClientOptions.DEFAULT_DEFAULT_PORT; + } + + final HttpMethod httpMethod = HttpMethod.valueOf(request.httpMethod().name()); + + final MultiMap headers = new HeadersMultiMap(); + request.headers().forEach((key, values) -> values.forEach(value -> headers.add(key, value))); + + final RequestOptions requestOptions = + new RequestOptions() + .setMethod(httpMethod) + .setHost(host) + .setPort(port) + .setURI(requestUri) + .setTimeout(timeout) + .setHeaders(headers); + + return this.httpClient.request(requestOptions).map(requestPreProcessor); + } +} diff --git a/vertx/src/test/java/feign/vertx/AbstractClientReconnectTest.java b/vertx/src/test/java/feign/vertx/AbstractClientReconnectTest.java new file mode 100644 index 000000000..04f20448c --- /dev/null +++ b/vertx/src/test/java/feign/vertx/AbstractClientReconnectTest.java @@ -0,0 +1,131 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; +import static org.assertj.core.api.Assertions.assertThat; + +import com.github.tomakehurst.wiremock.WireMockServer; +import feign.vertx.testcase.HelloServiceAPI; +import io.vertx.core.AsyncResult; +import io.vertx.core.CompositeFuture; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.junit5.VertxTestContext; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import org.junit.jupiter.api.*; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +abstract class AbstractClientReconnectTest extends AbstractFeignVertxTest { + static String baseUrl; + static int serverPort; + + HelloServiceAPI client = null; + + @BeforeAll + static void setupMockServer() { + serverPort = wireMock.port(); + baseUrl = wireMock.baseUrl(); + wireMock.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(200))); + } + + @BeforeAll + protected abstract void createClient(Vertx vertx); + + @Test + @DisplayName("All requests should be answered") + void testAllRequestsShouldBeAnswered(VertxTestContext testContext) { + sendRequests(10).compose(responses -> assertAllRequestsAnswered(responses, testContext)); + } + + @Nested + @DisplayName("After server has became unavailable") + class AfterServerBecameUnavailable { + + @BeforeEach + void shutDownServer() { + wireMock.stop(); + } + + @Test + @DisplayName("All requests should fail") + void testAllRequestsShouldFail(VertxTestContext testContext) { + sendRequests(10) + .onComplete( + responses -> + testContext.verify( + () -> { + if (responses.succeeded()) { + testContext.failNow( + new IllegalStateException( + "Client should not get responses from unavailable server")); + } + + try { + assertThat(responses.cause().getMessage()).startsWith("Connection "); + testContext.completeNow(); + } catch (Throwable assertionException) { + testContext.failNow(assertionException); + } + })); + } + + @Nested + @DisplayName("After server is available again") + class AfterServerIsBack { + WireMockServer restartedServer = new WireMockServer(options().port(serverPort)); + + @BeforeEach + void restartServer() { + restartedServer.start(); + restartedServer.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(200))); + } + + @AfterEach + void shutDownServer() { + restartedServer.stop(); + } + + @Test + @DisplayName("All requests should be answered") + void testAllRequestsShouldBeAnswered(VertxTestContext testContext) { + sendRequests(10).compose(responses -> assertAllRequestsAnswered(responses, testContext)); + } + } + } + + CompositeFuture sendRequests(int requests) { + List requestList = + IntStream.range(0, requests) + .mapToObj(ignored -> client.hello()) + .collect(Collectors.toList()); + return CompositeFuture.all(requestList); + } + + Future assertAllRequestsAnswered( + AsyncResult responses, VertxTestContext testContext) { + if (responses.succeeded()) { + testContext.completeNow(); + return Future.succeededFuture(); + } else { + testContext.failNow(responses.cause()); + return Future.failedFuture(responses.cause()); + } + } +} diff --git a/vertx/src/test/java/feign/vertx/AbstractFeignVertxTest.java b/vertx/src/test/java/feign/vertx/AbstractFeignVertxTest.java new file mode 100644 index 000000000..3a4ab6264 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/AbstractFeignVertxTest.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; + +import com.github.tomakehurst.wiremock.WireMockServer; +import feign.vertx.testcase.domain.OrderGenerator; +import io.vertx.junit5.VertxExtension; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(VertxExtension.class) +public abstract class AbstractFeignVertxTest { + protected static WireMockServer wireMock = new WireMockServer(options().dynamicPort()); + protected static final OrderGenerator generator = new OrderGenerator(); + + @BeforeAll + @DisplayName("Setup WireMock server") + static void setupWireMockServer() { + wireMock.start(); + } + + @AfterAll + @DisplayName("Shutdown WireMock server") + static void shutdownWireMockServer() { + wireMock.stop(); + } +} diff --git a/vertx/src/test/java/feign/vertx/ConnectionsLeakTests.java b/vertx/src/test/java/feign/vertx/ConnectionsLeakTests.java new file mode 100644 index 000000000..779e63a95 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/ConnectionsLeakTests.java @@ -0,0 +1,127 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static java.util.stream.Collectors.toList; +import static org.assertj.core.api.Assertions.assertThat; + +import feign.VertxFeign; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import feign.vertx.testcase.HelloServiceAPI; +import io.vertx.core.CompositeFuture; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.http.*; +import io.vertx.core.impl.ConcurrentHashSet; +import io.vertx.junit5.VertxExtension; +import io.vertx.junit5.VertxTestContext; +import java.util.List; +import java.util.Set; +import java.util.stream.IntStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(VertxExtension.class) +@DisplayName("Test that connections does not leak") +public class ConnectionsLeakTests { + private static final HttpServerOptions serverOptions = + new HttpServerOptions().setLogActivity(true).setPort(8091).setSsl(false); + + HttpServer httpServer; + + private final Set connections = new ConcurrentHashSet<>(); + + @BeforeEach + public void initServer(Vertx vertx) { + httpServer = vertx.createHttpServer(serverOptions); + httpServer.requestHandler( + request -> { + if (request.connection() != null) { + this.connections.add(request.connection()); + } + request.response().end("Hello world"); + }); + httpServer.listen(); + } + + @AfterEach + public void shutdownServer() { + httpServer.close(); + connections.clear(); + } + + @Test + @DisplayName("when use HTTP 1.1") + public void testHttp11NoConnectionLeak(Vertx vertx, VertxTestContext testContext) { + int pollSize = 3; + int nbRequests = 100; + + HttpClientOptions options = new HttpClientOptions().setMaxPoolSize(pollSize); + + HelloServiceAPI client = + VertxFeign.builder() + .vertx(vertx) + .options(options) + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .target(HelloServiceAPI.class, "http://localhost:8091"); + + assertNotLeaks(client, testContext, nbRequests, pollSize); + } + + @Test + @DisplayName("when use HTTP 2") + public void testHttp2NoConnectionLeak(Vertx vertx, VertxTestContext testContext) { + int pollSize = 1; + int nbRequests = 100; + + HttpClientOptions options = + new HttpClientOptions().setProtocolVersion(HttpVersion.HTTP_2).setHttp2MaxPoolSize(1); + + HelloServiceAPI client = + VertxFeign.builder() + .vertx(vertx) + .options(options) + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .target(HelloServiceAPI.class, "http://localhost:8091"); + + assertNotLeaks(client, testContext, nbRequests, pollSize); + } + + void assertNotLeaks( + HelloServiceAPI client, VertxTestContext testContext, int nbRequests, int pollSize) { + List futures = + IntStream.range(0, nbRequests).mapToObj(ignored -> client.hello()).collect(toList()); + + CompositeFuture.all(futures) + .onComplete( + ignored -> + testContext.verify( + () -> { + try { + assertThat(this.connections.size()).isEqualTo(pollSize); + testContext.completeNow(); + } catch (Throwable assertionFailure) { + testContext.failNow(assertionFailure); + } + })); + } +} diff --git a/vertx/src/test/java/feign/vertx/Http11ClientReconnectTest.java b/vertx/src/test/java/feign/vertx/Http11ClientReconnectTest.java new file mode 100644 index 000000000..1c443e1ee --- /dev/null +++ b/vertx/src/test/java/feign/vertx/Http11ClientReconnectTest.java @@ -0,0 +1,43 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import feign.VertxFeign; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import feign.vertx.testcase.HelloServiceAPI; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; + +@DisplayName("Tests of reconnection with HTTP 1.1") +public class Http11ClientReconnectTest extends AbstractClientReconnectTest { + + @BeforeAll + @Override + protected void createClient(final Vertx vertx) { + HttpClientOptions options = new HttpClientOptions().setMaxPoolSize(3); + + client = + VertxFeign.builder() + .vertx(vertx) + .options(options) + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .target(HelloServiceAPI.class, baseUrl); + } +} diff --git a/vertx/src/test/java/feign/vertx/Http2ClientReconnectTest.java b/vertx/src/test/java/feign/vertx/Http2ClientReconnectTest.java new file mode 100644 index 000000000..23c4d4eb2 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/Http2ClientReconnectTest.java @@ -0,0 +1,45 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import feign.VertxFeign; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import feign.vertx.testcase.HelloServiceAPI; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpVersion; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; + +@DisplayName("Tests of reconnection with HTTP 2") +public class Http2ClientReconnectTest extends AbstractClientReconnectTest { + + @BeforeAll + @Override + protected void createClient(Vertx vertx) { + HttpClientOptions options = + new HttpClientOptions().setProtocolVersion(HttpVersion.HTTP_2).setHttp2MaxPoolSize(1); + + client = + VertxFeign.builder() + .vertx(vertx) + .options(options) + .encoder(new JacksonEncoder()) + .decoder(new JacksonDecoder()) + .target(HelloServiceAPI.class, baseUrl); + } +} diff --git a/vertx/src/test/java/feign/vertx/QueryMapEncoderTest.java b/vertx/src/test/java/feign/vertx/QueryMapEncoderTest.java new file mode 100644 index 000000000..ef46034cc --- /dev/null +++ b/vertx/src/test/java/feign/vertx/QueryMapEncoderTest.java @@ -0,0 +1,117 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +import feign.*; +import feign.jackson.JacksonDecoder; +import feign.slf4j.Slf4jLogger; +import feign.vertx.testcase.domain.Bill; +import feign.vertx.testcase.domain.Flavor; +import feign.vertx.testcase.domain.IceCreamOrder; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.junit5.VertxTestContext; +import java.util.Collections; +import java.util.Comparator; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Tests of QueryMapEncoder") +public class QueryMapEncoderTest extends AbstractFeignVertxTest { + interface Api { + + @RequestLine("POST /icecream/orders") + Future makeOrder(@QueryMap IceCreamOrder order); + } + + Api client; + + @BeforeEach + void createClient(Vertx vertx) { + client = + VertxFeign.builder() + .vertx(vertx) + .decoder(new JacksonDecoder(TestUtils.MAPPER)) + .options(new HttpClientOptions().setLogActivity(true)) + .queryMapEncoder(new CustomQueryMapEncoder()) + .logger(new Slf4jLogger()) + .logLevel(Logger.Level.FULL) + .target(Api.class, wireMock.baseUrl()); + } + + @Test + @DisplayName("QueryMapEncoder will be used") + void testWillMakeOrder(VertxTestContext testContext) { + + /* Given */ + IceCreamOrder order = new IceCreamOrder(); + order.addBall(Flavor.PISTACHIO); + order.addBall(Flavor.PISTACHIO); + order.addBall(Flavor.STRAWBERRY); + order.addBall(Flavor.BANANA); + order.addBall(Flavor.VANILLA); + + Bill bill = Bill.makeBill(order); + String billStr = TestUtils.encodeAsJsonString(bill); + + wireMock.stubFor( + post(urlPathEqualTo("/icecream/orders")) + .withQueryParam("balls", equalTo("BANANA:1,PISTACHIO:2,STRAWBERRY:1,VANILLA:1")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(billStr))); + + /* When */ + Future billFuture = client.makeOrder(order); + + /* Then */ + billFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + assertThat(res.result()).isEqualTo(bill); + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + })); + } + + class CustomQueryMapEncoder implements QueryMapEncoder { + @Override + public Map encode(final Object o) { + IceCreamOrder order = (IceCreamOrder) o; + + String balls = + order.getBalls().entrySet().stream() + .sorted(Comparator.comparing(en -> en.getKey().toString())) + .map(entry -> entry.getKey().toString() + ':' + entry.getValue()) + .collect(Collectors.joining(",")); + + return Collections.singletonMap("balls", balls); + } + } +} diff --git a/vertx/src/test/java/feign/vertx/RawContractTest.java b/vertx/src/test/java/feign/vertx/RawContractTest.java new file mode 100644 index 000000000..c447fa414 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/RawContractTest.java @@ -0,0 +1,124 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static feign.vertx.testcase.domain.Flavor.FLAVORS_JSON; +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Response; +import feign.VertxFeign; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import feign.vertx.testcase.RawServiceAPI; +import feign.vertx.testcase.domain.Bill; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.junit5.VertxTestContext; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("When creating client from 'raw' contract") +public class RawContractTest extends AbstractFeignVertxTest { + static RawServiceAPI client; + + @BeforeAll + static void createClient(Vertx vertx) { + client = + VertxFeign.builder() + .vertx(vertx) + .encoder(new JacksonEncoder(TestUtils.MAPPER)) + .decoder(new JacksonDecoder(TestUtils.MAPPER)) + .target(RawServiceAPI.class, wireMock.baseUrl()); + } + + @Test + @DisplayName("should get available flavors") + public void testGetAvailableFlavors(VertxTestContext testContext) { + + /* Given */ + wireMock.stubFor( + get(urlEqualTo("/icecream/flavors")) + .withHeader("Accept", equalTo("application/json")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(FLAVORS_JSON))); + + /* When */ + Future flavorsFuture = client.getAvailableFlavors(); + + /* Then */ + flavorsFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + Response response = res.result(); + try { + String content = + new BufferedReader(new InputStreamReader(response.body().asInputStream())) + .lines() + .collect(Collectors.joining("\n")); + + assertThat(response.status()).isEqualTo(200); + assertThat(content).isEqualTo(FLAVORS_JSON); + testContext.completeNow(); + } catch (IOException ioException) { + testContext.failNow(ioException); + } + } else { + testContext.failNow(res.cause()); + } + })); + } + + @Test + @DisplayName("should pay bill") + public void testPayBill(VertxTestContext testContext) { + + /* Given */ + Bill bill = Bill.makeBill(generator.generate()); + String billStr = TestUtils.encodeAsJsonString(bill); + + wireMock.stubFor( + post(urlEqualTo("/icecream/bills/pay")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(billStr)) + .willReturn(aResponse().withStatus(200))); + + /* When */ + Future payedFuture = client.payBill(bill); + + /* Then */ + payedFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + })); + } +} diff --git a/vertx/src/test/java/feign/vertx/RequestPreProcessorTest.java b/vertx/src/test/java/feign/vertx/RequestPreProcessorTest.java new file mode 100644 index 000000000..1ae97c226 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/RequestPreProcessorTest.java @@ -0,0 +1,89 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static feign.vertx.testcase.domain.Flavor.FLAVORS_JSON; + +import feign.Logger; +import feign.VertxFeign; +import feign.jackson.JacksonDecoder; +import feign.slf4j.Slf4jLogger; +import feign.vertx.testcase.IcecreamServiceApi; +import feign.vertx.testcase.domain.Flavor; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.junit5.VertxTestContext; +import java.util.Arrays; +import java.util.Collection; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Test request pre processor") +public class RequestPreProcessorTest extends AbstractFeignVertxTest { + IcecreamServiceApi client; + + @BeforeEach + void createClient(Vertx vertx) { + client = + VertxFeign.builder() + .vertx(vertx) + .options(new HttpClientOptions().setLogActivity(true)) + .decoder(new JacksonDecoder(TestUtils.MAPPER)) + .requestPreProcessor(req -> req.putHeader("version", "v1")) + .logger(new Slf4jLogger()) + .logLevel(Logger.Level.FULL) + .target(IcecreamServiceApi.class, wireMock.baseUrl()); + } + + @Test + @DisplayName("request pre processor must be applied") + void testRequestPreProcessorMustApply(VertxTestContext testContext) { + + /* Given */ + wireMock.stubFor( + get(urlEqualTo("/icecream/flavors")) + .withHeader("Accept", equalTo("application/json")) + .withHeader("version", equalTo("v1")) + .willReturn( + aResponse() + .withStatus(200) + .withFixedDelay(100) + .withHeader("Content-Type", "application/json") + .withBody(FLAVORS_JSON))); + + Future> flavorsFuture = client.getAvailableFlavors(); + + /* Then */ + flavorsFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + Collection flavors = res.result(); + Assertions.assertThat(flavors) + .hasSize(Flavor.values().length) + .containsAll(Arrays.asList(Flavor.values())); + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + })); + } +} diff --git a/vertx/src/test/java/feign/vertx/RetryingTest.java b/vertx/src/test/java/feign/vertx/RetryingTest.java new file mode 100644 index 000000000..f6b5b2743 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/RetryingTest.java @@ -0,0 +1,140 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static com.github.tomakehurst.wiremock.stubbing.Scenario.STARTED; +import static feign.vertx.TestUtils.MAPPER; +import static feign.vertx.testcase.domain.Flavor.FLAVORS_JSON; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Logger; +import feign.RetryableException; +import feign.Retryer; +import feign.VertxFeign; +import feign.jackson.JacksonDecoder; +import feign.slf4j.Slf4jLogger; +import feign.vertx.testcase.IcecreamServiceApi; +import feign.vertx.testcase.domain.Flavor; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.junit5.VertxTestContext; +import java.util.Arrays; +import java.util.Collection; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("When server ask client to retry") +public class RetryingTest extends AbstractFeignVertxTest { + static IcecreamServiceApi client; + + @BeforeAll + static void createClient(Vertx vertx) { + client = + VertxFeign.builder() + .vertx(vertx) + .decoder(new JacksonDecoder(MAPPER)) + .retryer(new Retryer.Default(100, SECONDS.toMillis(1), 5)) + .logger(new Slf4jLogger()) + .logLevel(Logger.Level.FULL) + .target(IcecreamServiceApi.class, wireMock.baseUrl()); + } + + @Test + @DisplayName("should succeed when client retries less than max attempts") + public void testRetrying_success(VertxTestContext testContext) { + + /* Given */ + String scenario = "testRetrying_success"; + + wireMock.stubFor( + get(urlEqualTo("/icecream/flavors")) + .withHeader("Accept", equalTo("application/json")) + .inScenario(scenario) + .whenScenarioStateIs(STARTED) + .willReturn(aResponse().withStatus(503).withHeader("Retry-After", "1")) + .willSetStateTo("attempt1")); + + wireMock.stubFor( + get(urlEqualTo("/icecream/flavors")) + .withHeader("Accept", equalTo("application/json")) + .inScenario(scenario) + .whenScenarioStateIs("attempt1") + .willReturn(aResponse().withStatus(503).withHeader("Retry-After", "1")) + .willSetStateTo("attempt2")); + + wireMock.stubFor( + get(urlEqualTo("/icecream/flavors")) + .withHeader("Accept", equalTo("application/json")) + .inScenario(scenario) + .whenScenarioStateIs("attempt2") + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(FLAVORS_JSON))); + + /* When */ + Future> flavorsFuture = client.getAvailableFlavors(); + + /* Then */ + flavorsFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + assertThat(res.result()) + .hasSize(Flavor.values().length) + .containsAll(Arrays.asList(Flavor.values())); + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + })); + } + + @Test + @DisplayName("should fail when after max number of attempts") + public void testRetrying_noMoreAttempts(VertxTestContext testContext) { + + /* Given */ + wireMock.stubFor( + get(urlEqualTo("/icecream/flavors")) + .withHeader("Accept", equalTo("application/json")) + .willReturn(aResponse().withStatus(503).withHeader("Retry-After", "1"))); + + /* When */ + Future> flavorsFuture = client.getAvailableFlavors(); + + /* Then */ + flavorsFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.failed()) { + assertThat(res.cause()) + .isInstanceOf(RetryableException.class) + .hasMessageContaining("503 Service Unavailable"); + testContext.completeNow(); + } else { + testContext.failNow( + new IllegalStateException("RetryableException excepted but not occurred")); + } + })); + } +} diff --git a/vertx/src/test/java/feign/vertx/TestUtils.java b/vertx/src/test/java/feign/vertx/TestUtils.java new file mode 100644 index 000000000..6b9306eb1 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/TestUtils.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +class TestUtils { + static final ObjectMapper MAPPER = new ObjectMapper(); + + static { + MAPPER.registerModule(new JavaTimeModule()); + } + + static String encodeAsJsonString(final Object object) { + try { + return MAPPER.writeValueAsString(object); + } catch (JsonProcessingException unexpectedException) { + return ""; + } + } +} diff --git a/vertx/src/test/java/feign/vertx/TimeoutHandlingTest.java b/vertx/src/test/java/feign/vertx/TimeoutHandlingTest.java new file mode 100644 index 000000000..db093e014 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/TimeoutHandlingTest.java @@ -0,0 +1,122 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static feign.vertx.testcase.domain.Flavor.FLAVORS_JSON; +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Logger; +import feign.VertxFeign; +import feign.jackson.JacksonDecoder; +import feign.slf4j.Slf4jLogger; +import feign.vertx.testcase.IcecreamServiceApi; +import feign.vertx.testcase.domain.Flavor; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.junit5.VertxTestContext; +import java.util.Arrays; +import java.util.Collection; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Tests of handling of timeouts") +public class TimeoutHandlingTest extends AbstractFeignVertxTest { + IcecreamServiceApi client; + + @BeforeEach + void createClient(Vertx vertx) { + client = + VertxFeign.builder() + .vertx(vertx) + .decoder(new JacksonDecoder(TestUtils.MAPPER)) + .timeout(1000) + .options(new HttpClientOptions().setLogActivity(true)) + .logger(new Slf4jLogger()) + .logLevel(Logger.Level.FULL) + .target(IcecreamServiceApi.class, wireMock.baseUrl()); + } + + @Test + @DisplayName("when timeout is reached") + void testWhenTimeoutIsReached(VertxTestContext testContext) { + + /* Given */ + wireMock.stubFor( + get(urlEqualTo("/icecream/flavors")) + .withHeader("Accept", equalTo("application/json")) + .willReturn( + aResponse() + .withStatus(200) + .withFixedDelay(1500) + .withHeader("Content-Type", "application/json") + .withBody(FLAVORS_JSON))); + + Future> flavorsFuture = client.getAvailableFlavors(); + + /* Then */ + flavorsFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + testContext.failNow("should timeout!"); + } else { + assertThat(res.cause()) + .isInstanceOf(TimeoutException.class) + .hasMessageContaining("timeout"); + testContext.completeNow(); + } + })); + } + + @Test + @DisplayName("when timeout is not reached") + void testWhenTimeoutIsNotReached(VertxTestContext testContext) { + + /* Given */ + wireMock.stubFor( + get(urlEqualTo("/icecream/flavors")) + .withHeader("Accept", equalTo("application/json")) + .willReturn( + aResponse() + .withStatus(200) + .withFixedDelay(100) + .withHeader("Content-Type", "application/json") + .withBody(FLAVORS_JSON))); + + Future> flavorsFuture = client.getAvailableFlavors(); + + /* Then */ + flavorsFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + Collection flavors = res.result(); + assertThat(flavors) + .hasSize(Flavor.values().length) + .containsAll(Arrays.asList(Flavor.values())); + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + })); + } +} diff --git a/vertx/src/test/java/feign/vertx/VertxHttpClientTest.java b/vertx/src/test/java/feign/vertx/VertxHttpClientTest.java new file mode 100644 index 000000000..9d4980ada --- /dev/null +++ b/vertx/src/test/java/feign/vertx/VertxHttpClientTest.java @@ -0,0 +1,325 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static feign.vertx.testcase.domain.Flavor.FLAVORS_JSON; +import static feign.vertx.testcase.domain.Mixin.MIXINS_JSON; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import feign.FeignException; +import feign.Logger; +import feign.Request; +import feign.VertxFeign; +import feign.jackson.JacksonDecoder; +import feign.jackson.JacksonEncoder; +import feign.slf4j.Slf4jLogger; +import feign.vertx.testcase.IcecreamServiceApi; +import feign.vertx.testcase.IcecreamServiceApiBroken; +import feign.vertx.testcase.domain.Bill; +import feign.vertx.testcase.domain.Flavor; +import feign.vertx.testcase.domain.IceCreamOrder; +import feign.vertx.testcase.domain.Mixin; +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.junit5.VertxTestContext; +import java.util.Arrays; +import java.util.Collection; +import java.util.concurrent.TimeUnit; +import org.assertj.core.api.Assertions; +import org.assertj.core.api.ThrowableAssert; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayName("FeignVertx client") +public class VertxHttpClientTest extends AbstractFeignVertxTest { + + @Nested + @DisplayName("When make a GET request") + class WhenMakeGetRequest { + IcecreamServiceApi client; + + @BeforeEach + void createClient(Vertx vertx) { + client = + VertxFeign.builder() + .vertx(vertx) + .decoder(new JacksonDecoder(TestUtils.MAPPER)) + .options(new Request.Options(5L, TimeUnit.SECONDS, 5L, TimeUnit.SECONDS, true)) + .logger(new Slf4jLogger()) + .logLevel(Logger.Level.FULL) + .target(IcecreamServiceApi.class, wireMock.baseUrl()); + } + + @Test + @DisplayName("will get flavors") + void testWillGetFlavors(VertxTestContext testContext) { + + /* Given */ + wireMock.stubFor( + get(urlEqualTo("/icecream/flavors")) + .withHeader("Accept", equalTo("application/json")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(FLAVORS_JSON))); + + /* When */ + Future> flavorsFuture = client.getAvailableFlavors(); + + /* Then */ + flavorsFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + Collection flavors = res.result(); + + Assertions.assertThat(flavors) + .hasSize(Flavor.values().length) + .containsAll(Arrays.asList(Flavor.values())); + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + })); + } + + @Test + @DisplayName("will get mixins") + void testWillGetMixins(VertxTestContext testContext) { + + /* Given */ + wireMock.stubFor( + get(urlEqualTo("/icecream/mixins")) + .withHeader("Accept", equalTo("application/json")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(MIXINS_JSON))); + + /* When */ + Future> mixinsFuture = client.getAvailableMixins(); + + /* Then */ + mixinsFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + Collection mixins = res.result(); + + Assertions.assertThat(mixins) + .hasSize(Mixin.values().length) + .containsAll(Arrays.asList(Mixin.values())); + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + })); + } + + @Test + @DisplayName("will get order by id") + void testWillGetOrderById(VertxTestContext testContext) { + + /* Given */ + IceCreamOrder order = generator.generate(); + int orderId = order.getId(); + String orderStr = TestUtils.encodeAsJsonString(order); + + wireMock.stubFor( + get(urlEqualTo("/icecream/orders/" + orderId)) + .withHeader("Accept", equalTo("application/json")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(orderStr))); + + /* When */ + Future orderFuture = client.findOrder(orderId); + + /* Then */ + orderFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + assertThat(res.result()).isEqualTo(order); + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + })); + } + + @Test + @DisplayName("will return 404 when try to get non-existing order by id") + void testWillReturn404WhenTryToGetNonExistingOrderById(VertxTestContext testContext) { + + /* Given */ + wireMock.stubFor( + get(urlEqualTo("/icecream/orders/123")) + .withHeader("Accept", equalTo("application/json")) + .willReturn(aResponse().withStatus(404))); + + /* When */ + Future orderFuture = client.findOrder(123); + + /* Then */ + orderFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.failed()) { + assertThat(res.cause()) + .isInstanceOf(FeignException.class) + .hasMessageContaining("404 Not Found"); + testContext.completeNow(); + } else { + testContext.failNow( + new IllegalStateException("FeignException excepted but not occurred")); + } + })); + } + } + + @Nested + @DisplayName("When make a POST request") + class WhenMakePostRequest { + IcecreamServiceApi client; + + @BeforeEach + void createClient(Vertx vertx) { + client = + VertxFeign.builder() + .vertx(vertx) + .encoder(new JacksonEncoder(TestUtils.MAPPER)) + .decoder(new JacksonDecoder(TestUtils.MAPPER)) + .target(IcecreamServiceApi.class, wireMock.baseUrl()); + } + + @Test + @DisplayName("will make an order") + void testWillMakeOrder(VertxTestContext testContext) { + + /* Given */ + IceCreamOrder order = generator.generate(); + Bill bill = Bill.makeBill(order); + String orderStr = TestUtils.encodeAsJsonString(order); + String billStr = TestUtils.encodeAsJsonString(bill); + + wireMock.stubFor( + post(urlEqualTo("/icecream/orders")) + .withHeader("Content-Type", equalTo("application/json")) + .withHeader("Accept", equalTo("application/json")) + .withRequestBody(equalToJson(orderStr)) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(billStr))); + + /* When */ + Future billFuture = client.makeOrder(order); + + /* Then */ + billFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + assertThat(res.result()).isEqualTo(bill); + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + })); + } + + @Test + @DisplayName("will pay bill") + void testWillPayBill(VertxTestContext testContext) { + + /* Given */ + Bill bill = Bill.makeBill(generator.generate()); + String billStr = TestUtils.encodeAsJsonString(bill); + + wireMock.stubFor( + post(urlEqualTo("/icecream/bills/pay")) + .withHeader("Content-Type", equalTo("application/json")) + .withRequestBody(equalToJson(billStr)) + .willReturn(aResponse().withStatus(200))); + + /* When */ + Future payedFuture = client.payBill(bill); + + /* Then */ + payedFuture.onComplete( + res -> + testContext.verify( + () -> { + if (res.succeeded()) { + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + })); + } + } + + @Nested + @DisplayName("Should fail client instantiation") + class ShouldFailedClientInstantiation { + + @Test + @DisplayName("when Vertx is not provided") + void testWhenVertxMissing() { + + /* Given */ + ThrowableAssert.ThrowingCallable instantiateContractForgottenVertx = + () -> VertxFeign.builder().target(IcecreamServiceApi.class, wireMock.baseUrl()); + + /* Then */ + assertThatCode(instantiateContractForgottenVertx) + .isInstanceOf(NullPointerException.class) + .hasMessage("Vertx instance wasn't provided in VertxFeign builder"); + } + + @Test + @DisplayName("when try to instantiate contract that have method that not return future") + void testWhenTryToInstantiateBrokenContract(Vertx vertx) { + + /* Given */ + ThrowableAssert.ThrowingCallable instantiateBrokenContract = + () -> + VertxFeign.builder() + .vertx(vertx) + .target(IcecreamServiceApiBroken.class, wireMock.baseUrl()); + + /* Then */ + assertThatCode(instantiateBrokenContract) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("IcecreamServiceApiBroken#findOrder(int)"); + } + } +} diff --git a/vertx/src/test/java/feign/vertx/VertxHttpOptionsTest.java b/vertx/src/test/java/feign/vertx/VertxHttpOptionsTest.java new file mode 100644 index 000000000..c779fb7a6 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/VertxHttpOptionsTest.java @@ -0,0 +1,109 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static feign.vertx.testcase.domain.Flavor.FLAVORS_JSON; + +import feign.Logger; +import feign.Request; +import feign.VertxFeign; +import feign.jackson.JacksonDecoder; +import feign.slf4j.Slf4jLogger; +import feign.vertx.testcase.IcecreamServiceApi; +import feign.vertx.testcase.domain.Flavor; +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpClientOptions; +import io.vertx.core.http.HttpVersion; +import io.vertx.junit5.VertxTestContext; +import java.util.Arrays; +import java.util.Collection; +import java.util.concurrent.TimeUnit; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("FeignVertx client should be created from") +public class VertxHttpOptionsTest extends AbstractFeignVertxTest { + + @BeforeAll + static void setupStub() { + wireMock.stubFor( + get(urlEqualTo("/icecream/flavors")) + .withHeader("Accept", equalTo("application/json")) + .willReturn( + aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(FLAVORS_JSON))); + } + + @Test + @DisplayName("HttpClientOptions from Vertx") + public void testHttpClientOptions(Vertx vertx, VertxTestContext testContext) { + HttpClientOptions options = + new HttpClientOptions() + .setProtocolVersion(HttpVersion.HTTP_2) + .setHttp2MaxPoolSize(1) + .setConnectTimeout(5000) + .setIdleTimeout(5000); + + IcecreamServiceApi client = + VertxFeign.builder() + .vertx(vertx) + .options(options) + .decoder(new JacksonDecoder(TestUtils.MAPPER)) + .logger(new Slf4jLogger()) + .logLevel(Logger.Level.FULL) + .target(IcecreamServiceApi.class, wireMock.baseUrl()); + + testClient(client, testContext); + } + + @Test + @DisplayName("Request Options from Feign") + public void testRequestOptions(Vertx vertx, VertxTestContext testContext) { + IcecreamServiceApi client = + VertxFeign.builder() + .vertx(vertx) + .options(new Request.Options(5L, TimeUnit.SECONDS, 5L, TimeUnit.SECONDS, true)) + .decoder(new JacksonDecoder(TestUtils.MAPPER)) + .logger(new Slf4jLogger()) + .logLevel(Logger.Level.FULL) + .target(IcecreamServiceApi.class, wireMock.baseUrl()); + + testClient(client, testContext); + } + + private void testClient(IcecreamServiceApi client, VertxTestContext testContext) { + client + .getAvailableFlavors() + .onComplete( + res -> { + if (res.succeeded()) { + Collection flavors = res.result(); + + Assertions.assertThat(flavors) + .hasSize(Flavor.values().length) + .containsAll(Arrays.asList(Flavor.values())); + testContext.completeNow(); + } else { + testContext.failNow(res.cause()); + } + }); + } +} diff --git a/vertx/src/test/java/feign/vertx/testcase/HelloServiceAPI.java b/vertx/src/test/java/feign/vertx/testcase/HelloServiceAPI.java new file mode 100644 index 000000000..0cfec3631 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/testcase/HelloServiceAPI.java @@ -0,0 +1,33 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx.testcase; + +import feign.Headers; +import feign.RequestLine; +import feign.Response; +import io.vertx.core.Future; + +/** + * Example of an API to to test number of Http2 connections of Feign. + * + * @author James Xu + */ +@Headers({"Accept: application/json"}) +public interface HelloServiceAPI { + + @RequestLine("GET /hello") + Future hello(); +} diff --git a/vertx/src/test/java/feign/vertx/testcase/IcecreamServiceApi.java b/vertx/src/test/java/feign/vertx/testcase/IcecreamServiceApi.java new file mode 100644 index 000000000..3963a8df9 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/testcase/IcecreamServiceApi.java @@ -0,0 +1,52 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx.testcase; + +import feign.Headers; +import feign.Param; +import feign.RequestLine; +import feign.vertx.testcase.domain.Bill; +import feign.vertx.testcase.domain.Flavor; +import feign.vertx.testcase.domain.IceCreamOrder; +import feign.vertx.testcase.domain.Mixin; +import io.vertx.core.Future; +import java.util.Collection; + +/** + * API of an iceream web service. + * + * @author Alexei KLENIN + */ +@Headers({"Accept: application/json"}) +public interface IcecreamServiceApi { + + @RequestLine("GET /icecream/flavors") + Future> getAvailableFlavors(); + + @RequestLine("GET /icecream/mixins") + Future> getAvailableMixins(); + + @RequestLine("POST /icecream/orders") + @Headers("Content-Type: application/json") + Future makeOrder(IceCreamOrder order); + + @RequestLine("GET /icecream/orders/{orderId}") + Future findOrder(@Param("orderId") int orderId); + + @RequestLine("POST /icecream/bills/pay") + @Headers("Content-Type: application/json") + Future payBill(Bill bill); +} diff --git a/vertx/src/test/java/feign/vertx/testcase/IcecreamServiceApiBroken.java b/vertx/src/test/java/feign/vertx/testcase/IcecreamServiceApiBroken.java new file mode 100644 index 000000000..d50d7d9b7 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/testcase/IcecreamServiceApiBroken.java @@ -0,0 +1,50 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx.testcase; + +import feign.Headers; +import feign.Param; +import feign.RequestLine; +import feign.vertx.VertxDelegatingContract; +import feign.vertx.testcase.domain.Bill; +import feign.vertx.testcase.domain.Flavor; +import feign.vertx.testcase.domain.IceCreamOrder; +import feign.vertx.testcase.domain.Mixin; +import io.vertx.core.Future; +import java.util.Collection; + +/** + * API of an iceream web service with one method that doesn't returns {@link Future} and violates + * {@link VertxDelegatingContract}s rules. + * + * @author Alexei KLENIN + */ +public interface IcecreamServiceApiBroken { + + @RequestLine("GET /icecream/flavors") + Future> getAvailableFlavors(); + + @RequestLine("GET /icecream/mixins") + Future> getAvailableMixins(); + + @RequestLine("POST /icecream/orders") + @Headers("Content-Type: application/json") + Future makeOrder(IceCreamOrder order); + + /** Method that doesn't respects contract. */ + @RequestLine("GET /icecream/orders/{orderId}") + IceCreamOrder findOrder(@Param("orderId") int orderId); +} diff --git a/vertx/src/test/java/feign/vertx/testcase/RawServiceAPI.java b/vertx/src/test/java/feign/vertx/testcase/RawServiceAPI.java new file mode 100644 index 000000000..ae0dd93e3 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/testcase/RawServiceAPI.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx.testcase; + +import feign.Headers; +import feign.RequestLine; +import feign.Response; +import feign.vertx.testcase.domain.Bill; +import io.vertx.core.Future; + +/** + * Example of an API to to test rarely used features of Feign. + * + * @author Alexei KLENIN + */ +@Headers({"Accept: application/json"}) +public interface RawServiceAPI { + + @RequestLine("GET /icecream/flavors") + Future getAvailableFlavors(); + + @RequestLine("POST /icecream/bills/pay") + @Headers("Content-Type: application/json") + Future payBill(Bill bill); +} diff --git a/vertx/src/test/java/feign/vertx/testcase/domain/Bill.java b/vertx/src/test/java/feign/vertx/testcase/domain/Bill.java new file mode 100644 index 000000000..49f257b99 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/testcase/domain/Bill.java @@ -0,0 +1,82 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx.testcase.domain; + +import java.util.Map; +import java.util.Objects; + +/** + * Bill for consumed ice cream. + * + * @author Alexei KLENIN + */ +public class Bill { + private static final Map PRICES = + Map.of( + 1, (float) 2.00, // two euros for one ball (expensive!) + 3, (float) 2.85, // 2.85€ for 3 balls + 5, (float) 4.30, // 4.30€ for 5 balls + 7, (float) 5); // only five euros for seven balls! Wow + + private static final float MIXIN_PRICE = (float) 0.6; // price per mixin + + private Float price; + + public Bill() {} + + public Bill(final Float price) { + this.price = price; + } + + public Float getPrice() { + return price; + } + + public void setPrice(final Float price) { + this.price = price; + } + + /** + * Makes a bill from an order. + * + * @param order ice cream order + * @return bill + */ + public static Bill makeBill(final IceCreamOrder order) { + int nbBalls = order.getBalls().values().stream().mapToInt(Integer::intValue).sum(); + Float price = PRICES.get(nbBalls) + order.getMixins().size() * MIXIN_PRICE; + return new Bill(price); + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + + if (!(other instanceof Bill)) { + return false; + } + + final Bill another = (Bill) other; + return Objects.equals(price, another.price); + } + + @Override + public int hashCode() { + return Objects.hash(price); + } +} diff --git a/vertx/src/test/java/feign/vertx/testcase/domain/Flavor.java b/vertx/src/test/java/feign/vertx/testcase/domain/Flavor.java new file mode 100644 index 000000000..34e0c3d5b --- /dev/null +++ b/vertx/src/test/java/feign/vertx/testcase/domain/Flavor.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx.testcase.domain; + +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Ice cream flavors. + * + * @author Alexei KLENIN + */ +public enum Flavor { + STRAWBERRY, + CHOCOLATE, + BANANA, + PISTACHIO, + MELON, + VANILLA; + + public static final String FLAVORS_JSON = + Stream.of(Flavor.values()) + .map(flavor -> "\"" + flavor + "\"") + .collect(Collectors.joining(", ", "[ ", " ]")); +} diff --git a/vertx/src/test/java/feign/vertx/testcase/domain/IceCreamOrder.java b/vertx/src/test/java/feign/vertx/testcase/domain/IceCreamOrder.java new file mode 100644 index 000000000..2569eebee --- /dev/null +++ b/vertx/src/test/java/feign/vertx/testcase/domain/IceCreamOrder.java @@ -0,0 +1,111 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx.testcase.domain; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; + +/** + * Give me some ice-cream! :p + * + * @author Alexei KLENIN + */ +public class IceCreamOrder { + private final int id; // order id + private final Map balls; // how much balls of flavor + private final Set mixins; // and some mixins ... + private Instant orderTimestamp; // and give it to me right now ! + + public IceCreamOrder() { + this(Instant.now()); + } + + IceCreamOrder(final Instant orderTimestamp) { + this.id = ThreadLocalRandom.current().nextInt(); + this.balls = new HashMap<>(); + this.mixins = new LinkedHashSet<>(); + this.orderTimestamp = orderTimestamp; + } + + public IceCreamOrder addBall(final Flavor ballFlavor) { + final Integer ballCount = balls.containsKey(ballFlavor) ? balls.get(ballFlavor) + 1 : 1; + balls.put(ballFlavor, ballCount); + return this; + } + + IceCreamOrder addMixin(final Mixin mixin) { + mixins.add(mixin); + return this; + } + + IceCreamOrder withOrderTimestamp(final Instant orderTimestamp) { + this.orderTimestamp = orderTimestamp; + return this; + } + + public int getId() { + return id; + } + + public Map getBalls() { + return balls; + } + + public Set getMixins() { + return mixins; + } + + public Instant getOrderTimestamp() { + return orderTimestamp; + } + + @Override + public boolean equals(final Object other) { + if (this == other) { + return true; + } + + if (!(other instanceof IceCreamOrder)) { + return false; + } + + final IceCreamOrder another = (IceCreamOrder) other; + return id == another.id + && Objects.equals(balls, another.balls) + && Objects.equals(mixins, another.mixins) + && Objects.equals(orderTimestamp, another.orderTimestamp); + } + + @Override + public int hashCode() { + return Objects.hash(id, balls, mixins, orderTimestamp); + } + + @Override + public String toString() { + return "IceCreamOrder{" + + " id=" + + id + + ", balls=" + + balls + + ", mixins=" + + mixins + + ", orderTimestamp=" + + orderTimestamp + + '}'; + } +} diff --git a/vertx/src/test/java/feign/vertx/testcase/domain/Mixin.java b/vertx/src/test/java/feign/vertx/testcase/domain/Mixin.java new file mode 100644 index 000000000..18db1a450 --- /dev/null +++ b/vertx/src/test/java/feign/vertx/testcase/domain/Mixin.java @@ -0,0 +1,38 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx.testcase.domain; + +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * Ice cream mix-ins. + * + * @author Alexei KLENIN + */ +public enum Mixin { + COOKIES, + MNMS, + CHOCOLATE_SIROP, + STRAWBERRY_SIROP, + NUTS, + RAINBOW; + + public static final String MIXINS_JSON = + Stream.of(Mixin.values()) + .map(flavor -> "\"" + flavor + "\"") + .collect(Collectors.joining(", ", "[ ", " ]")); +} diff --git a/vertx/src/test/java/feign/vertx/testcase/domain/OrderGenerator.java b/vertx/src/test/java/feign/vertx/testcase/domain/OrderGenerator.java new file mode 100644 index 000000000..e411b18cd --- /dev/null +++ b/vertx/src/test/java/feign/vertx/testcase/domain/OrderGenerator.java @@ -0,0 +1,59 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * 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 feign.vertx.testcase.domain; + +import java.util.Random; +import java.util.stream.IntStream; + +/** + * Generator of random ice cream orders. + * + * @author Alexei KLENIN + */ +public class OrderGenerator { + private static final int[] BALLS_NUMBER = {1, 3, 5, 7}; + private static final int[] MIXIN_NUMBER = {1, 2, 3}; + + private static final Random random = new Random(); + + public IceCreamOrder generate() { + final IceCreamOrder order = new IceCreamOrder(); + final int nbBalls = peekBallsNumber(); + final int nbMixins = peekMixinNumber(); + + IntStream.rangeClosed(1, nbBalls).mapToObj(i -> this.peekFlavor()).forEach(order::addBall); + + IntStream.rangeClosed(1, nbMixins).mapToObj(i -> this.peekMixin()).forEach(order::addMixin); + + return order; + } + + private int peekBallsNumber() { + return BALLS_NUMBER[random.nextInt(BALLS_NUMBER.length)]; + } + + private int peekMixinNumber() { + return MIXIN_NUMBER[random.nextInt(MIXIN_NUMBER.length)]; + } + + private Flavor peekFlavor() { + return Flavor.values()[random.nextInt(Flavor.values().length)]; + } + + private Mixin peekMixin() { + return Mixin.values()[random.nextInt(Mixin.values().length)]; + } +} diff --git a/vertx/src/test/resources/log4j.properties b/vertx/src/test/resources/log4j.properties new file mode 100644 index 000000000..2f1bc83fc --- /dev/null +++ b/vertx/src/test/resources/log4j.properties @@ -0,0 +1,6 @@ +log4j.rootLogger=DEBUG, stdout + +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.Target=System.out +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n \ No newline at end of file