diff --git a/README.md b/README.md index 6b79aded0e..6e6446dfe2 100644 --- a/README.md +++ b/README.md @@ -444,6 +444,35 @@ A Map parameter can be annotated with `QueryMap` to construct a query that uses V find(@QueryMap Map queryMap); ``` +This may also be used to generate the query parameters from a POJO object using a `QueryMapEncoder`. + +```java +@RequestLine("GET /find") +V find(@QueryMap CustomPojo customPojo); +``` + +When used in this manner, without specifying a custom `QueryMapEncoder`, the query map will be generated using member variable names as query parameter names. The following POJO will generate query params of "/find?name={name}&number={number}" (order of included query parameters not guaranteed, and as usual, if any value is null, it will be left out). + +```java +public class CustomPojo { + private final String name; + private final int number; + + public CustomPojo (String name, int number) { + this.name = name; + this.number = number; + } +} +``` + +To setup a custom `QueryMapEncoder`: + +```java +MyApi myApi = Feign.builder() + .queryMapEncoder(new MyCustomQueryMapEncoder()) + .target(MyApi.class, "https://api.hostname.com"); +``` + #### Static and Default Methods Interfaces targeted by Feign may have static or default methods (if using Java 8+). These allows Feign clients to contain logic that is not expressly defined by the underlying API. diff --git a/core/src/main/java/feign/Contract.java b/core/src/main/java/feign/Contract.java index 189efe8445..cb77d0ed51 100644 --- a/core/src/main/java/feign/Contract.java +++ b/core/src/main/java/feign/Contract.java @@ -123,7 +123,9 @@ protected MethodMetadata parseAndValidateMetadata(Class targetType, Method me } if (data.queryMapIndex() != null) { - checkMapString("QueryMap", parameterTypes[data.queryMapIndex()], genericParameterTypes[data.queryMapIndex()]); + if (Map.class.isAssignableFrom(parameterTypes[data.queryMapIndex()])) { + checkMapKeys("QueryMap", genericParameterTypes[data.queryMapIndex()]); + } } return data; @@ -132,6 +134,10 @@ protected MethodMetadata parseAndValidateMetadata(Class targetType, Method me private static void checkMapString(String name, Class type, Type genericType) { checkState(Map.class.isAssignableFrom(type), "%s parameter must be a Map: %s", name, type); + checkMapKeys(name, genericType); + } + + private static void checkMapKeys(String name, Type genericType) { Type[] parameterTypes = ((ParameterizedType) genericType).getActualTypeArguments(); Class keyClass = (Class) parameterTypes[0]; checkState(String.class.equals(keyClass), diff --git a/core/src/main/java/feign/Feign.java b/core/src/main/java/feign/Feign.java index b07369d31f..6d60f1b617 100644 --- a/core/src/main/java/feign/Feign.java +++ b/core/src/main/java/feign/Feign.java @@ -101,6 +101,7 @@ public static class Builder { private Logger logger = new NoOpLogger(); private Encoder encoder = new Encoder.Default(); private Decoder decoder = new Decoder.Default(); + private QueryMapEncoder queryMapEncoder = new QueryMapEncoder.Default(); private ErrorDecoder errorDecoder = new ErrorDecoder.Default(); private Options options = new Options(); private InvocationHandlerFactory invocationHandlerFactory = @@ -143,6 +144,11 @@ public Builder decoder(Decoder decoder) { return this; } + public Builder queryMapEncoder(QueryMapEncoder queryMapEncoder) { + this.queryMapEncoder = queryMapEncoder; + return this; + } + /** * Allows to map the response before passing it to the decoder. */ @@ -241,9 +247,9 @@ public Feign build() { new SynchronousMethodHandler.Factory(client, retryer, requestInterceptors, logger, logLevel, decode404, closeAfterDecode); ParseHandlersByName handlersByName = - new ParseHandlersByName(contract, options, encoder, decoder, + new ParseHandlersByName(contract, options, encoder, decoder, queryMapEncoder, errorDecoder, synchronousMethodHandlerFactory); - return new ReflectiveFeign(handlersByName, invocationHandlerFactory); + return new ReflectiveFeign(handlersByName, invocationHandlerFactory, queryMapEncoder); } } diff --git a/core/src/main/java/feign/QueryMapEncoder.java b/core/src/main/java/feign/QueryMapEncoder.java new file mode 100644 index 0000000000..b6909823b4 --- /dev/null +++ b/core/src/main/java/feign/QueryMapEncoder.java @@ -0,0 +1,88 @@ +/** + * Copyright 2012-2018 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import feign.codec.EncodeException; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A QueryMapEncoder encodes Objects into maps of query parameter names to values. + */ +public interface QueryMapEncoder { + + /** + * Encodes the given object into a query map. + * + * @param object the object to encode + * @return the map represented by the object + */ + Map encode (Object object); + + class Default implements QueryMapEncoder { + + private final Map, ObjectParamMetadata> classToMetadata = + new HashMap, ObjectParamMetadata>(); + + @Override + public Map encode (Object object) throws EncodeException { + try { + ObjectParamMetadata metadata = getMetadata(object.getClass()); + Map fieldNameToValue = new HashMap(); + for (Field field : metadata.objectFields) { + Object value = field.get(object); + if (value != null && value != object) { + fieldNameToValue.put(field.getName(), value); + } + } + return fieldNameToValue; + } catch (IllegalAccessException e) { + throw new EncodeException("Failure encoding object into query map", e); + } + } + + private ObjectParamMetadata getMetadata(Class objectType) { + ObjectParamMetadata metadata = classToMetadata.get(objectType); + if (metadata == null) { + metadata = ObjectParamMetadata.parseObjectType(objectType); + classToMetadata.put(objectType, metadata); + } + return metadata; + } + + private static class ObjectParamMetadata { + + private final List objectFields; + + private ObjectParamMetadata (List objectFields) { + this.objectFields = Collections.unmodifiableList(objectFields); + } + + private static ObjectParamMetadata parseObjectType(Class type) { + List fields = new ArrayList(); + for (Field field : type.getDeclaredFields()) { + if (!field.isAccessible()) { + field.setAccessible(true); + } + fields.add(field); + } + return new ObjectParamMetadata(fields); + } + } + } +} diff --git a/core/src/main/java/feign/ReflectiveFeign.java b/core/src/main/java/feign/ReflectiveFeign.java index fc3ea9273e..91332e8671 100644 --- a/core/src/main/java/feign/ReflectiveFeign.java +++ b/core/src/main/java/feign/ReflectiveFeign.java @@ -29,16 +29,17 @@ import static feign.Util.checkArgument; import static feign.Util.checkNotNull; -import static feign.Util.checkState; public class ReflectiveFeign extends Feign { private final ParseHandlersByName targetToHandlersByName; private final InvocationHandlerFactory factory; + private final QueryMapEncoder queryMapEncoder; - ReflectiveFeign(ParseHandlersByName targetToHandlersByName, InvocationHandlerFactory factory) { + ReflectiveFeign(ParseHandlersByName targetToHandlersByName, InvocationHandlerFactory factory, QueryMapEncoder queryMapEncoder) { this.targetToHandlersByName = targetToHandlersByName; this.factory = factory; + this.queryMapEncoder = queryMapEncoder; } /** @@ -128,14 +129,22 @@ static final class ParseHandlersByName { private final Encoder encoder; private final Decoder decoder; private final ErrorDecoder errorDecoder; + private final QueryMapEncoder queryMapEncoder; private final SynchronousMethodHandler.Factory factory; - ParseHandlersByName(Contract contract, Options options, Encoder encoder, Decoder decoder, - ErrorDecoder errorDecoder, SynchronousMethodHandler.Factory factory) { + ParseHandlersByName( + Contract contract, + Options options, + Encoder encoder, + Decoder decoder, + QueryMapEncoder queryMapEncoder, + ErrorDecoder errorDecoder, + SynchronousMethodHandler.Factory factory) { this.contract = contract; this.options = options; this.factory = factory; this.errorDecoder = errorDecoder; + this.queryMapEncoder = queryMapEncoder; this.encoder = checkNotNull(encoder, "encoder"); this.decoder = checkNotNull(decoder, "decoder"); } @@ -146,11 +155,11 @@ public Map apply(Target key) { for (MethodMetadata md : metadata) { BuildTemplateByResolvingArgs buildTemplate; if (!md.formParams().isEmpty() && md.template().bodyTemplate() == null) { - buildTemplate = new BuildFormEncodedTemplateFromArgs(md, encoder); + buildTemplate = new BuildFormEncodedTemplateFromArgs(md, encoder, queryMapEncoder); } else if (md.bodyIndex() != null) { - buildTemplate = new BuildEncodedTemplateFromArgs(md, encoder); + buildTemplate = new BuildEncodedTemplateFromArgs(md, encoder, queryMapEncoder); } else { - buildTemplate = new BuildTemplateByResolvingArgs(md); + buildTemplate = new BuildTemplateByResolvingArgs(md, queryMapEncoder); } result.put(md.configKey(), factory.create(key, md, buildTemplate, options, decoder, errorDecoder)); @@ -161,11 +170,14 @@ public Map apply(Target key) { private static class BuildTemplateByResolvingArgs implements RequestTemplate.Factory { + private final QueryMapEncoder queryMapEncoder; + protected final MethodMetadata metadata; private final Map indexToExpander = new LinkedHashMap(); - private BuildTemplateByResolvingArgs(MethodMetadata metadata) { + private BuildTemplateByResolvingArgs(MethodMetadata metadata, QueryMapEncoder queryMapEncoder) { this.metadata = metadata; + this.queryMapEncoder = queryMapEncoder; if (metadata.indexToExpander() != null) { indexToExpander.putAll(metadata.indexToExpander()); return; @@ -212,7 +224,9 @@ public RequestTemplate create(Object[] argv) { if (metadata.queryMapIndex() != null) { // add query map parameters after initial resolve so that they take // precedence over any predefined values - template = addQueryMapQueryParameters((Map) argv[metadata.queryMapIndex()], template); + Object value = argv[metadata.queryMapIndex()]; + Map queryMap = toQueryMap(value); + template = addQueryMapQueryParameters(queryMap, template); } if (metadata.headerMapIndex() != null) { @@ -222,6 +236,17 @@ public RequestTemplate create(Object[] argv) { return template; } + private Map toQueryMap (Object value) { + if (value instanceof Map) { + return (Map)value; + } + try { + return queryMapEncoder.encode(value); + } catch (EncodeException e) { + throw new IllegalStateException(e); + } + } + private Object expandElements(Expander expander, Object value) { if (value instanceof Iterable) { return expandIterable(expander, (Iterable) value); @@ -231,7 +256,7 @@ private Object expandElements(Expander expander, Object value) { private List expandIterable(Expander expander, Iterable value) { List values = new ArrayList(); - for (Object element : (Iterable) value) { + for (Object element : value) { if (element!=null) { values.add(expander.expand(element)); } @@ -300,8 +325,8 @@ private static class BuildFormEncodedTemplateFromArgs extends BuildTemplateByRes private final Encoder encoder; - private BuildFormEncodedTemplateFromArgs(MethodMetadata metadata, Encoder encoder) { - super(metadata); + private BuildFormEncodedTemplateFromArgs(MethodMetadata metadata, Encoder encoder, QueryMapEncoder queryMapEncoder) { + super(metadata, queryMapEncoder); this.encoder = encoder; } @@ -329,8 +354,8 @@ private static class BuildEncodedTemplateFromArgs extends BuildTemplateByResolvi private final Encoder encoder; - private BuildEncodedTemplateFromArgs(MethodMetadata metadata, Encoder encoder) { - super(metadata); + private BuildEncodedTemplateFromArgs(MethodMetadata metadata, Encoder encoder, QueryMapEncoder queryMapEncoder) { + super(metadata, queryMapEncoder); this.encoder = encoder; } diff --git a/core/src/test/java/feign/CustomPojo.java b/core/src/test/java/feign/CustomPojo.java new file mode 100644 index 0000000000..8a162afbeb --- /dev/null +++ b/core/src/test/java/feign/CustomPojo.java @@ -0,0 +1,25 @@ +/** + * Copyright 2012-2018 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +public class CustomPojo { + + private final String name; + private final Integer number; + + CustomPojo(String name, Integer number) { + this.name = name; + this.number = number; + } +} diff --git a/core/src/test/java/feign/DefaultContractTest.java b/core/src/test/java/feign/DefaultContractTest.java index 5f6b01359a..556c9edfe6 100644 --- a/core/src/test/java/feign/DefaultContractTest.java +++ b/core/src/test/java/feign/DefaultContractTest.java @@ -316,16 +316,6 @@ public void onlyOneQueryMapAnnotationPermitted() throws Exception { } } - @Test - public void queryMapMustBeInstanceOfMap() throws Exception { - try { - parseAndValidateMetadata(QueryMapTestInterface.class, "nonMapQueryMap", String.class); - Fail.failBecauseExceptionWasNotThrown(IllegalStateException.class); - } catch (IllegalStateException ex) { - assertThat(ex).hasMessage("QueryMap parameter must be a Map: class java.lang.String"); - } - } - @Test public void queryMapKeysMustBeStrings() throws Exception { try { @@ -336,6 +326,29 @@ public void queryMapKeysMustBeStrings() throws Exception { } } + @Test + public void queryMapPojoObject() throws Exception { + MethodMetadata md = parseAndValidateMetadata(QueryMapTestInterface.class, "pojoObject", Object.class); + + assertThat(md.queryMapIndex()).isEqualTo(0); + } + + @Test + public void queryMapPojoObjectEncoded() throws Exception { + MethodMetadata md = parseAndValidateMetadata(QueryMapTestInterface.class, "pojoObjectEncoded", Object.class); + + assertThat(md.queryMapIndex()).isEqualTo(0); + assertThat(md.queryMapEncoded()).isTrue(); + } + + @Test + public void queryMapPojoObjectNotEncoded() throws Exception { + MethodMetadata md = parseAndValidateMetadata(QueryMapTestInterface.class, "pojoObjectNotEncoded", Object.class); + + assertThat(md.queryMapIndex()).isEqualTo(0); + assertThat(md.queryMapEncoded()).isFalse(); + } + @Test public void slashAreEncodedWhenNeeded() throws Exception { MethodMetadata md = parseAndValidateMetadata(SlashNeedToBeEncoded.class, @@ -501,13 +514,18 @@ interface QueryMapTestInterface { @RequestLine("POST /") void queryMapNotEncoded(@QueryMap(encoded = false) Map queryMap); - // invalid @RequestLine("POST /") - void multipleQueryMap(@QueryMap Map mapOne, @QueryMap Map mapTwo); + void pojoObject(@QueryMap Object object); + + @RequestLine("POST /") + void pojoObjectEncoded(@QueryMap(encoded = true) Object object); + + @RequestLine("POST /") + void pojoObjectNotEncoded(@QueryMap(encoded = false) Object object); // invalid @RequestLine("POST /") - void nonMapQueryMap(@QueryMap String notAMap); + void multipleQueryMap(@QueryMap Map mapOne, @QueryMap Map mapTwo); // invalid @RequestLine("POST /") diff --git a/core/src/test/java/feign/DefaultQueryMapEncoderTest.java b/core/src/test/java/feign/DefaultQueryMapEncoderTest.java new file mode 100644 index 0000000000..63df04da72 --- /dev/null +++ b/core/src/test/java/feign/DefaultQueryMapEncoderTest.java @@ -0,0 +1,78 @@ +/** + * Copyright 2012-2018 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +import java.util.HashMap; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class DefaultQueryMapEncoderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private final QueryMapEncoder encoder = new QueryMapEncoder.Default(); + + @Test + public void testEncodesObject_visibleFields() { + Map expected = new HashMap<>(); + expected.put("foo", "fooz"); + expected.put("bar", "barz"); + expected.put("baz", "bazz"); + VisibleFieldsObject object = new VisibleFieldsObject(); + object.foo = "fooz"; + object.bar = "barz"; + object.baz = "bazz"; + + Map encodedMap = encoder.encode(object); + assertEquals("Unexpected encoded query map", expected, encodedMap); + } + + @Test + public void testEncodesObject_visibleFields_emptyObject() { + VisibleFieldsObject object = new VisibleFieldsObject(); + Map encodedMap = encoder.encode(object); + assertTrue("Non-empty map generated from null fields: " + encodedMap, encodedMap.isEmpty()); + } + + @Test + public void testEncodesObject_nonVisibleFields() { + Map expected = new HashMap<>(); + expected.put("foo", "fooz"); + expected.put("bar", "barz"); + QueryMapEncoderObject object = new QueryMapEncoderObject("fooz", "barz"); + + Map encodedMap = encoder.encode(object); + assertEquals("Unexpected encoded query map", expected, encodedMap); + } + + @Test + public void testEncodesObject_nonVisibleFields_emptyObject() { + QueryMapEncoderObject object = new QueryMapEncoderObject(null, null); + Map encodedMap = encoder.encode(object); + assertTrue("Non-empty map generated from null fields", encodedMap.isEmpty()); + } + + static class VisibleFieldsObject { + String foo; + String bar; + String baz; + } +} + diff --git a/core/src/test/java/feign/FeignBuilderTest.java b/core/src/test/java/feign/FeignBuilderTest.java index 03b068d07d..501a3c14f7 100644 --- a/core/src/test/java/feign/FeignBuilderTest.java +++ b/core/src/test/java/feign/FeignBuilderTest.java @@ -13,6 +13,7 @@ */ package feign; +import java.util.HashMap; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -186,6 +187,28 @@ public Object decode(Response response, Type type) { assertEquals(1, server.getRequestCount()); } + @Test + public void testOverrideQueryMapEncoder() throws Exception { + server.enqueue(new MockResponse()); + + String url = "http://localhost:" + server.getPort(); + QueryMapEncoder customMapEncoder = new QueryMapEncoder() { + @Override + public Map encode(Object ignored) { + Map queryMap = new HashMap(); + queryMap.put("key1", "value1"); + queryMap.put("key2", "value2"); + return queryMap; + } + }; + + TestInterface api = Feign.builder().queryMapEncoder(customMapEncoder).target(TestInterface.class, url); + api.queryMapEncoded("ignored"); + + assertThat(server.takeRequest()).hasQueryParams(Arrays.asList("key1=value1", "key2=value2")); + assertEquals(1, server.getRequestCount()); + } + @Test public void testProvideRequestInterceptors() throws Exception { server.enqueue(new MockResponse().setBody("response data")); @@ -333,6 +356,9 @@ interface TestInterface { @RequestLine("GET api/thing") Response getNoInitialSlashOnSlash(); + @RequestLine(value = "GET /api/querymap/object") + String queryMapEncoded(@QueryMap Object object); + @RequestLine("POST /") Response codecPost(String data); diff --git a/core/src/test/java/feign/FeignTest.java b/core/src/test/java/feign/FeignTest.java index 245f8092dc..fcba7548ad 100644 --- a/core/src/test/java/feign/FeignTest.java +++ b/core/src/test/java/feign/FeignTest.java @@ -25,7 +25,6 @@ import java.util.HashMap; import java.util.LinkedHashMap; import okio.Buffer; -import org.assertj.core.api.Fail; import org.assertj.core.data.MapEntry; import org.junit.Rule; import org.junit.Test; @@ -384,6 +383,42 @@ public void queryMapValueStartingWithBrace() throws Exception { .hasPath("/?%7Bname=%7Balice"); } + @Test + public void queryMapPojoWithFullParams() throws Exception { + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + CustomPojo customPojo = new CustomPojo("Name", 3); + + server.enqueue(new MockResponse()); + api.queryMapPojo(customPojo); + assertThat(server.takeRequest()) + .hasQueryParams(Arrays.asList("name=Name", "number=3")); + } + + @Test + public void queryMapPojoWithPartialParams() throws Exception { + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + CustomPojo customPojo = new CustomPojo("Name", null); + + server.enqueue(new MockResponse()); + api.queryMapPojo(customPojo); + assertThat(server.takeRequest()) + .hasPath("/?name=Name"); + } + + @Test + public void queryMapPojoWithEmptyParams() throws Exception { + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + CustomPojo customPojo = new CustomPojo(null, null); + + server.enqueue(new MockResponse()); + api.queryMapPojo(customPojo); + assertThat(server.takeRequest()) + .hasPath("/"); + } + @Test public void configKeyFormatsAsExpected() throws Exception { assertEquals("TestInterface#post()", @@ -590,7 +625,7 @@ public void decodingDoesNotSwallow404ErrorsInDecode404Mode() throws Exception { .decode404() .errorDecoder(new IllegalArgumentExceptionOn404()) .target("http://localhost:" + server.getPort()); - api.queryMap(Collections.emptyMap()); + api.queryMap(Collections.emptyMap()); } @Test @@ -796,6 +831,9 @@ void form( @RequestLine("GET /?trim={trim}") void encodedQueryParam(@Param(value = "trim", encoded = true) String trim); + @RequestLine("GET /") + void queryMapPojo(@QueryMap CustomPojo object); + class DateToMillis implements Param.Expander { @Override diff --git a/core/src/test/java/feign/QueryMapEncoderObject.java b/core/src/test/java/feign/QueryMapEncoderObject.java new file mode 100644 index 0000000000..5ca8b5113b --- /dev/null +++ b/core/src/test/java/feign/QueryMapEncoderObject.java @@ -0,0 +1,24 @@ +/** + * Copyright 2012-2018 The Feign Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License + * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express + * or implied. See the License for the specific language governing permissions and limitations under + * the License. + */ +package feign; + +class QueryMapEncoderObject { + private final String foo; + private final String bar; + + QueryMapEncoderObject (String foo, String bar) { + this.foo = foo; + this.bar = bar; + } +} diff --git a/core/src/test/java/feign/assertj/RecordedRequestAssert.java b/core/src/test/java/feign/assertj/RecordedRequestAssert.java index 4f752ef3a2..b10c9c1901 100644 --- a/core/src/test/java/feign/assertj/RecordedRequestAssert.java +++ b/core/src/test/java/feign/assertj/RecordedRequestAssert.java @@ -13,6 +13,9 @@ */ package feign.assertj; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; import okhttp3.Headers; import okhttp3.mockwebserver.RecordedRequest; @@ -62,6 +65,29 @@ public RecordedRequestAssert hasPath(String expected) { return this; } + public RecordedRequestAssert hasQueryParams(String... expectedParams) { + return hasQueryParams(Arrays.asList(expectedParams)); + } + + public RecordedRequestAssert hasQueryParams(Collection expectedParams) { + isNotNull(); + Collection actualQueryParams = getQueryParams(); + objects.assertEqual(info, expectedParams.size(), actualQueryParams.size()); + for (String expectedParam : expectedParams) { + objects.assertIsIn(info, expectedParam, actualQueryParams); + } + return this; + } + + private Collection getQueryParams() { + String path = actual.getPath(); + int queryStart = path.indexOf("?") + 1; + String[] queryParams = actual.getPath() + .substring(queryStart) + .split("&"); + return Arrays.asList(queryParams); + } + public RecordedRequestAssert hasOneOfPath(String... expected) { isNotNull(); objects.assertIsIn(info, actual.getPath(), expected);