From 64a73585df28d5636e0315e8ac51d41615b58624 Mon Sep 17 00:00:00 2001 From: Tom Simmons Date: Sat, 8 Oct 2022 23:01:01 -0400 Subject: [PATCH] Issues/31 (#36) Improve URL manipulation support with UriTemplate (and Encoders) This is primarily a change to the Encoders to support form-style string expansion and continuation as separate encoding strategies, which aligns better with RFC6570. This makes query parameter support in UriTemplate possible without major modifications to UriTemplate. --- .../github/tomboyo/lily/example/Example.java | 23 +- .../tomboyo/lily/compiler/cg/AstToJava.java | 26 +- .../tomboyo/lily/compiler/PathsTest.java | 2 +- .../github/tomboyo/lily/http/UriTemplate.java | 126 ++++++-- .../tomboyo/lily/http/encoding/Encoder.java | 14 + .../tomboyo/lily/http/encoding/Encoders.java | 104 +++++++ .../tomboyo/lily/http/encoding/Encoding.java | 39 --- .../http/encoding/FormExplodeFactory.java | 9 +- .../http/encoding/FormExplodeGenerator.java | 192 ++++++++---- .../lily/http/encoding/EncodersTest.java | 286 ++++++++++++++++++ .../lily/http/encoding/EncodingTest.java | 185 ----------- .../lily/http/encoding/UriTemplateTest.java | 74 +++-- 12 files changed, 722 insertions(+), 358 deletions(-) create mode 100644 modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoder.java create mode 100644 modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoders.java delete mode 100644 modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoding.java create mode 100644 modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/EncodersTest.java delete mode 100644 modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/EncodingTest.java diff --git a/modules/example/src/test/java/io/github/tomboyo/lily/example/Example.java b/modules/example/src/test/java/io/github/tomboyo/lily/example/Example.java index 683d618..26c1082 100644 --- a/modules/example/src/test/java/io/github/tomboyo/lily/example/Example.java +++ b/modules/example/src/test/java/io/github/tomboyo/lily/example/Example.java @@ -3,6 +3,9 @@ import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.ok; import static com.github.tomakehurst.wiremock.client.WireMock.stubFor; +import static io.github.tomboyo.lily.http.encoding.Encoders.Modifiers.EXPLODE; +import static io.github.tomboyo.lily.http.encoding.Encoders.form; +import static io.github.tomboyo.lily.http.encoding.Encoders.simple; import static org.junit.jupiter.api.Assertions.assertEquals; import com.fasterxml.jackson.core.type.TypeReference; @@ -10,10 +13,9 @@ import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo; import com.github.tomakehurst.wiremock.junit5.WireMockTest; import io.github.tomboyo.lily.http.JacksonBodyHandler; -import io.github.tomboyo.lily.http.encoding.Encoding; -import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; +import java.util.Map; import org.junit.jupiter.api.Test; @WireMockTest @@ -89,22 +91,23 @@ void manual(WireMockRuntimeInfo info) throws Exception { Api.newBuilder() .uri(info.getHttpBaseUrl()) .build() - .petsOperations() // All operations with the `pets` tag. (We could also use - // .allOperations()) + // All operations with the `pets` tag. (We could also use .allOperations()) + .petsOperations() .showPetById(); // the GET /pets/{petId} operation var uri = operation // Access the underlying uri template to finish the request manually .uriTemplate() - // Bind "1234" to the petId path parameter. The Encoding class supports several formats, - // like formExplode for - // query parameters. We can override values already set using the operation API. - .put("petId", Encoding.simple(1234)) + // Bind "1234" to the petId path parameter. The Encoders class implements several common + // formats. We can override bindings set by the operation. + .bind("petId", 1234, simple()) + // The operation doesn't have any query parameter templates, so we'll add one and bind a + // value to it. + .appendTemplate("{queryParameters}") + .bind("queryParameters", Map.of("foo", "foo", "bar", "bar"), form(EXPLODE)) .toURI(); - uri = URI.create(uri.toString() + "?foo=foo&bar=bar"); - /* * Finish and send the request manually. Note the use of the generated Pet type. All components schemas and * parameter schemas are generated. Also note the use of the provided lily-http JacksonBodyHandler. diff --git a/modules/lily-compiler/src/main/java/io/github/tomboyo/lily/compiler/cg/AstToJava.java b/modules/lily-compiler/src/main/java/io/github/tomboyo/lily/compiler/cg/AstToJava.java index 36123e0..5062651 100644 --- a/modules/lily-compiler/src/main/java/io/github/tomboyo/lily/compiler/cg/AstToJava.java +++ b/modules/lily-compiler/src/main/java/io/github/tomboyo/lily/compiler/cg/AstToJava.java @@ -154,7 +154,12 @@ public class {{className}} { private final String uri; private {{className}}(String uri) { - this.uri = java.util.Objects.requireNonNull(uri); + java.util.Objects.requireNonNull(uri); + if (uri.endsWith("/")) { + this.uri = uri; + } else { + this.uri = uri + "/"; + } } public static {{className}}Builder newBuilder() { @@ -208,6 +213,7 @@ public class {{className}} { private final String uri; public {{className}}(String uri) { + // Assumed non-null and to end with a trailing '/'. this.uri = uri; } @@ -245,7 +251,8 @@ public class {{className}} { private final io.github.tomboyo.lily.http.UriTemplate uriTemplate; public {{className}}(String uri) { - this.uriTemplate = io.github.tomboyo.lily.http.UriTemplate.forPath(uri, "{{{relativePath}}}"); + // We assume uri is non-null and ends with a trailing '/'. + this.uriTemplate = io.github.tomboyo.lily.http.UriTemplate.of(uri + "{{{relativePath}}}"); } {{#pathParameters}} @@ -259,9 +266,10 @@ public class {{className}} { public io.github.tomboyo.lily.http.UriTemplate uriTemplate() { {{#pathParameters}} if (this.{{name}} != null) { - uriTemplate.put( + uriTemplate.bind( "{{oasName}}", - io.github.tomboyo.lily.http.encoding.Encoding.simple(this.{{name}})); + this.{{name}}, + io.github.tomboyo.lily.http.encoding.Encoders.simple()); } {{/pathParameters}} return uriTemplate; @@ -272,7 +280,7 @@ public io.github.tomboyo.lily.http.UriTemplate uriTemplate() { Map.of( "packageName", ast.operationClass().name().packageName(), "className", ast.operationClass().name().simpleName(), - "relativePath", ast.relativePath(), + "relativePath", withoutLeadingSlash(ast.relativePath()), "pathParameters", ast.parameters().stream() .filter(parameter -> parameter.location() == PATH) @@ -291,4 +299,12 @@ public io.github.tomboyo.lily.http.UriTemplate uriTemplate() { private static Source createSource(Fqn fqn, String content) { return new Source(fqn.toPath(), fqn.toString(), content); } + + private static String withoutLeadingSlash(String path) { + if (path.startsWith("/")) { + return path.substring(1); + } else { + return path; + } + } } diff --git a/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/PathsTest.java b/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/PathsTest.java index 5b4e9c9..365b37a 100644 --- a/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/PathsTest.java +++ b/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/PathsTest.java @@ -177,7 +177,7 @@ void templatesAreExposedForAllOperations() { .allOperations() .getPetById() .uriTemplate() - .put("id", "some-uuid-here") + .bind("id", "some-uuid-here") .toURI(); """ .formatted(packageName)), diff --git a/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/UriTemplate.java b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/UriTemplate.java index 164eebc..e07186c 100644 --- a/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/UriTemplate.java +++ b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/UriTemplate.java @@ -1,55 +1,122 @@ package io.github.tomboyo.lily.http; -import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Objects.requireNonNullElse; +import io.github.tomboyo.lily.http.encoding.Encoder; +import io.github.tomboyo.lily.http.encoding.Encoders; import java.net.URI; -import java.net.URLEncoder; -import java.util.Arrays; import java.util.HashMap; import java.util.regex.Pattern; -import java.util.stream.Collectors; -import java.util.stream.Stream; +/** + * A utility that creates URIs from template strings and parameter bindings. + * + *
+ *   UriTemplate
+ *     .of("https://example.com/{myParam}/{query}{continuation}")
+ *     .bind("myParam", "some;value")
+ *     .bind("query", Map.of("key", "value?"), Encoders.form(EXPLODE))
+ *     .bind("continuation", List.of("a", "b"), Encoders.formContinuation(EXPLODE))
+ *     .toURI()
+ *     .toString();
+ *     // => https://example.com/some;value/?key=value%3F&continuation=a&continuation=b
+ * 
+ * + * @see Encoders + */ public class UriTemplate { - private final String template; - private final HashMap parameters; + private String template; + private final HashMap bindings; - private UriTemplate(String template) { + private UriTemplate(String template, HashMap bindings) { this.template = template; - parameters = new HashMap<>(); + this.bindings = bindings; } - public static UriTemplate forPath(String first, String... rest) { - var uri = - Stream.concat(Stream.of(first), Arrays.stream(rest)) - .map(UriTemplate::removeLeadingAndTrailingSlash) - .collect(Collectors.joining("/")); - return new UriTemplate(uri); + /** + * Create a UriTemplate from the given string. + * + * @param template The template string. + * @return A UriTemplate for the given template strings. + */ + // Note: we do not support a `String first, String... rest` API because some scenarios are + // ambiguous; consider `of("http://example.com/", "{pathParameter}", "{queryParameter}")`. + public static UriTemplate of(String template) { + return new UriTemplate(template, new HashMap<>()); } - private static String removeLeadingAndTrailingSlash(String part) { - if (part.codePointAt(0) == '/') { - part = part.substring(1); - } + /** + * Replace this UriTemplate's template string with the given string. + * + * @param template The new template string. + * @return This instance for chaining. + */ + public UriTemplate withTemplate(String template) { + this.template = template; + return this; + } - if (part.codePointAt(part.length() - 1) == '/') { - part = part.substring(0, part.length() - 1); + /** + * Append the given template string to the end of this instance's template string. + * + * @param more The string to append to the current template. + * @return This instance for chaining. + */ + public UriTemplate appendTemplate(String more) { + this.template += more; + return this; + } + + /** + * Bind a URL-encoded string to template parameters with the given name, once per name. + * + * @param parameter The name of an unbound template parameter + * @param value A URL-encoded value + * @throws IllegalStateException if the parameter has already been bound to a value. + * @return This instance for chaining. + */ + public UriTemplate bind(String parameter, String value) { + if (bindings.put(parameter, value) != null) { + throw new IllegalStateException("Parameter already bound: name='" + parameter + "'"); } + return this; + } - return part; + /** + * Bind an object to a template parameter, using the given Encoder to expand the object to a URL- + * encoded string. + * + * @param parameter The name of an unbound template parameter. + * @param value The value to bind to the parameter. + * @param encoder The Encoder used to expand the object to a string. + * @throws IllegalStateException if the parameter has already been bound to a value. + * @return This instance for chaining. + */ + public UriTemplate bind(String parameter, Object value, Encoder encoder) { + if (bindings.put(parameter, encoder.encode(parameter, value)) != null) { + throw new IllegalStateException("Parameter already bound: name='" + parameter + "'"); + } + return this; } - public UriTemplate put(String name, String value) { - parameters.put(name, value); + /** + * Remove the value, if any, bound to the template parameter with the given name. + * + * @param parameter The name of the template parameter. + * @return This instance for chaining. + */ + public UriTemplate unbind(String parameter) { + bindings.put(parameter, null); return this; } /** - * Create a URI from the given template and interpolated, URL-encoded parameters. + * Create the finished URI from the template and bound parameters. + * + *

Empty parameters are encoded as empty strings. * * @return The finished URI. - * @throws UriTemplateException If the URI cannot be generated for any reason. */ public URI toURI() { var pattern = Pattern.compile("\\{([^{}]+)}"); // "{parameterName}" @@ -59,11 +126,8 @@ public URI toURI() { .replaceAll( (matchResult -> { var name = template.substring(matchResult.start() + 1, matchResult.end() - 1); - var value = parameters.get(name); - if (value == null) { - throw new UriTemplateException("No value set for parameter named " + name); - } - return URLEncoder.encode(value, UTF_8); + var value = bindings.get(name); + return requireNonNullElse(value, ""); })); return URI.create(uri); } diff --git a/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoder.java b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoder.java new file mode 100644 index 0000000..eabbd71 --- /dev/null +++ b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoder.java @@ -0,0 +1,14 @@ +package io.github.tomboyo.lily.http.encoding; + +/** + * A function with formats and URL-encodes given objects, returning a string suitable for + * constructing URLs. + * + * @see Encoders + * @see io.github.tomboyo.lily.http.UriTemplate + */ +@FunctionalInterface +public interface Encoder { + /** Produce an appropriate URL-encoded string for the given parameter name and value. */ + String encode(String parameterName, Object value); +} diff --git a/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoders.java b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoders.java new file mode 100644 index 0000000..fbb39da --- /dev/null +++ b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoders.java @@ -0,0 +1,104 @@ +package io.github.tomboyo.lily.http.encoding; + +import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS; +import static io.github.tomboyo.lily.http.encoding.Encoders.Modifiers.EXPLODE; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.Map; + +/** + * A collection of Encoder implementations for frequently-used formats, such as RFC6570 simple- and + * form-style string expansion. + * + * @see io.github.tomboyo.lily.http.UriTemplate + */ +public class Encoders { + + private static final ObjectMapper simpleMapper = + new ObjectMapper(new SimpleFactory()) + .registerModule(new JavaTimeModule()) + .configure(WRITE_DATES_AS_TIMESTAMPS, false); + + private static final ObjectMapper formExplodeMapper = + new ObjectMapper(new FormExplodeFactory("?")) + .registerModule(new JavaTimeModule()) + .configure(WRITE_DATES_AS_TIMESTAMPS, false); + + private static final ObjectMapper formContinuationExplodeMapper = + new ObjectMapper(new FormExplodeFactory("&")) + .registerModule(new JavaTimeModule()) + .configure(WRITE_DATES_AS_TIMESTAMPS, false); + + private Encoders() {} + + public enum Modifiers { + EXPLODE; + } + + /** + * Returns an encoder which implements RFC6570 simple-style string expansion. + * + * @param modifiers A list of string expansion modifiers to parameterize the encoding strategy. + * @return The encoder. + */ + public static Encoder simple(Modifiers... modifiers) { + if (modifiers.length > 0) { + throw new UnsupportedOperationException( + "Only non-exploded simple expansion is currently supported"); + } + + return (String paramName, Object o) -> { + try { + return simpleMapper.writer().writeValueAsString(o); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + }; + } + + /** + * Returns an Encoder which implements RFC6570 form-style string expansion. + * + * @param modifiers A list of string expansion modifiers to parameterize the encoding strategy. + * @return The encoder. + */ + public static Encoder form(Modifiers... modifiers) { + if (Arrays.asList(modifiers).contains(EXPLODE)) { + return (String paramName, Object o) -> { + try { + return formExplodeMapper.writer().writeValueAsString(Map.of(paramName, o)); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + }; + } else { + throw new UnsupportedOperationException( + "Only form-style expansion with explode is currently supported"); + } + } + + /** + * Returns an Encoder which implements RFC6570 form-style continuation. + * + * @param modifiers A list of string expansion modifiers to parameterize the encoding strategy. + * @return The encoder. + */ + public static Encoder formContinuation(Modifiers... modifiers) { + if (Arrays.asList(modifiers).contains(EXPLODE)) { + return (String paramName, Object o) -> { + try { + return formContinuationExplodeMapper.writeValueAsString(Map.of(paramName, o)); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + }; + } else { + throw new UnsupportedOperationException( + "Only form-style expansion with explode is currently supported"); + } + } +} diff --git a/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoding.java b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoding.java deleted file mode 100644 index 8f90105..0000000 --- a/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/Encoding.java +++ /dev/null @@ -1,39 +0,0 @@ -package io.github.tomboyo.lily.http.encoding; - -import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import java.io.UncheckedIOException; - -public class Encoding { - - private static final ObjectMapper simpleMapper = - new ObjectMapper(new SimpleFactory()) - .registerModule(new JavaTimeModule()) - .configure(WRITE_DATES_AS_TIMESTAMPS, false); - - private static final ObjectMapper formExplodeMapper = - new ObjectMapper(new FormExplodeFactory()) - .registerModule(new JavaTimeModule()) - .configure(WRITE_DATES_AS_TIMESTAMPS, false); - - private Encoding() {} - - public static String simple(Object o) { - try { - return simpleMapper.writer().writeValueAsString(o); - } catch (JsonProcessingException e) { - throw new UncheckedIOException(e); - } - } - - public static String formExplode(Object o) { - try { - return formExplodeMapper.writer().writeValueAsString(o); - } catch (JsonProcessingException e) { - throw new UncheckedIOException(e); - } - } -} diff --git a/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/FormExplodeFactory.java b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/FormExplodeFactory.java index 997dd32..8cb0a2e 100644 --- a/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/FormExplodeFactory.java +++ b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/FormExplodeFactory.java @@ -5,8 +5,15 @@ import java.io.Writer; class FormExplodeFactory extends JsonFactory { + + private final String leadingCharacter; + + public FormExplodeFactory(String leadingCharacter) { + this.leadingCharacter = leadingCharacter; + } + @Override public JsonGenerator createGenerator(Writer w) { - return new FormExplodeGenerator(w); + return new FormExplodeGenerator(leadingCharacter, w); } } diff --git a/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/FormExplodeGenerator.java b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/FormExplodeGenerator.java index 372befc..7513b72 100644 --- a/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/FormExplodeGenerator.java +++ b/modules/lily-http/src/main/java/io/github/tomboyo/lily/http/encoding/FormExplodeGenerator.java @@ -1,171 +1,204 @@ package io.github.tomboyo.lily.http.encoding; +import static java.nio.charset.StandardCharsets.UTF_8; + import com.fasterxml.jackson.core.Base64Variant; import com.fasterxml.jackson.core.base.GeneratorBase; import java.io.IOException; import java.io.Writer; import java.math.BigDecimal; import java.math.BigInteger; +import java.net.URLEncoder; /** - * Expands all objects according to RFC6570 form string expansion. + * Expands all objects according to RFC6570 form-style query expansion or query continuation with + * the 'explode' modifier, like {@code ?key=value&key=value} and {@code &key=value&key=value}. All + * key and value pairs are URL encoded, as requried. + * + *

All objects to be encoded MUST be passed in as a Map with one key-value pair, where the key is + * the name of the parameter to encode and the value is the object. For example, to encode the + * parameter named "keys" which is an object {@code { a: 1, b: 2 }}, the encoder should be given the + * map {@code { keys: { a: 1, b: 2 }}} as an argument. The encoder MAY ignore the parameter name + * depending on the type of the object to encode. * - *

See https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#style-examples - * and https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8. + *

Refer to the following resources: + * + *

    + *
  • https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#style-examples + *
  • https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8 + *
  • https://www.rfc-editor.org/rfc/rfc6570#section-3.2.9 + *
*/ public class FormExplodeGenerator extends GeneratorBase { private final Writer writer; - private boolean isObjectStarted = false; - private String lastFieldName = null; + private final String leadingCharacter; + + // The name of the parameter being expanded, which is given as the key of the singleton key-value + // pair object passed as argument to this generator. The value is already URL-encoded. + private String parameterName; + + // True when we still need to write the leading character (e.g. the '?' at the beginning of a + // query string) + private boolean writeLeadingCharacter = true; + + // 1 indicates that we have read the parameterName from the wrapping object, and + // 2 indicates that we are encoding an object argument. + // 3 or greater suggests a nested object, which is not supported. + private int objectDepth = 0; + + // The field/key name for the object parameter currently being written. + private String currentField = null; + + // True when we are writing array elements. private boolean isInArray = false; - private boolean isFirstArrayItem = true; - public FormExplodeGenerator(Writer writer) { + public FormExplodeGenerator(String leadingCharacter, Writer writer) { super(0, null); + this.leadingCharacter = leadingCharacter; this.writer = writer; } @Override - public void writeStartArray() throws IOException { - requireInObject(); + public void writeStartArray() { + if (isInArray || objectDepth == 2) { + throw new IllegalStateException("Nested arrays are not supported"); + } + isInArray = true; - isFirstArrayItem = true; } @Override - public void writeEndArray() throws IOException { + public void writeEndArray() { isInArray = false; } @Override - public void writeStartObject() throws IOException { - if (isObjectStarted) { - throw new UnsupportedOperationException("Nested objects are not supported"); + public void writeStartObject() { + objectDepth += 1; + if (objectDepth > 2 || isInArray) { + throw new IllegalStateException("Nested objects are not supported"); } } @Override - public void writeEndObject() throws IOException {} + public void writeEndObject() { + objectDepth -= 1; + } @Override - public void writeFieldName(String name) throws IOException { - if (isObjectStarted) { - writer.write('&'); - } else { - isObjectStarted = true; + public void writeFieldName(String name) { + if (objectDepth == 1) { + if (parameterName != null) { + throw new IllegalStateException("Expected a top-level singleton map but got a multiton"); + } + + // Retrieve the name of the parameter to encode from the KV wrapper. We don't need to write + // anything yet -- whether the key is written depends on the type of the object to encode. + parameterName = URLEncoder.encode(name, UTF_8); + return; } - lastFieldName = name; - writer.write(name); - writer.write('='); + currentField = URLEncoder.encode(name, UTF_8); } @Override public void writeString(String text) throws IOException { - requireInObject(); - handleExplodedArray(); - writer.write(text); + writeJoiner(); + writer.write(URLEncoder.encode(text, UTF_8)); } @Override - public void writeString(char[] buffer, int offset, int len) throws IOException { + public void writeString(char[] buffer, int offset, int len) { throw new UnsupportedOperationException(); } @Override - public void writeRawUTF8String(byte[] buffer, int offset, int len) throws IOException { + public void writeRawUTF8String(byte[] buffer, int offset, int len) { throw new UnsupportedOperationException(); } @Override - public void writeUTF8String(byte[] buffer, int offset, int len) throws IOException { + public void writeUTF8String(byte[] buffer, int offset, int len) { throw new UnsupportedOperationException(); } @Override - public void writeRaw(String text) throws IOException { + public void writeRaw(String text) { throw new UnsupportedOperationException(); } @Override - public void writeRaw(String text, int offset, int len) throws IOException { + public void writeRaw(String text, int offset, int len) { throw new UnsupportedOperationException(); } @Override - public void writeRaw(char[] text, int offset, int len) throws IOException { + public void writeRaw(char[] text, int offset, int len) { throw new UnsupportedOperationException(); } @Override - public void writeRaw(char c) throws IOException { + public void writeRaw(char c) { throw new UnsupportedOperationException(); } @Override - public void writeBinary(Base64Variant bv, byte[] data, int offset, int len) throws IOException { + public void writeBinary(Base64Variant bv, byte[] data, int offset, int len) { throw new UnsupportedOperationException(); } @Override public void writeNumber(int v) throws IOException { - handleExplodedArray(); + writeJoiner(); writer.write(Integer.toString(v)); } @Override public void writeNumber(long v) throws IOException { - requireInObject(); - handleExplodedArray(); + writeJoiner(); writer.write(Long.toString(v)); } @Override public void writeNumber(BigInteger v) throws IOException { - requireInObject(); - handleExplodedArray(); + writeJoiner(); writer.write(v.toString()); } @Override public void writeNumber(double v) throws IOException { - requireInObject(); - handleExplodedArray(); + writeJoiner(); writer.write(Double.toString(v)); } @Override public void writeNumber(float v) throws IOException { - requireInObject(); - handleExplodedArray(); + writeJoiner(); writer.write(Float.toString(v)); } @Override public void writeNumber(BigDecimal v) throws IOException { - requireInObject(); - handleExplodedArray(); + writeJoiner(); writer.write(v.toString()); } @Override - public void writeNumber(String encodedValue) throws IOException { + public void writeNumber(String encodedValue) { throw new UnsupportedOperationException(); } @Override public void writeBoolean(boolean state) throws IOException { - requireInObject(); - handleExplodedArray(); + writeJoiner(); writer.write(Boolean.toString(state)); } @Override public void writeNull() throws IOException { - requireInObject(); - handleExplodedArray(); + writeJoiner(); } @Override @@ -177,27 +210,54 @@ public void flush() throws IOException { protected void _releaseBuffers() {} @Override - protected void _verifyValueWrite(String typeMsg) throws IOException {} + protected void _verifyValueWrite(String typeMsg) {} - private void handleExplodedArray() throws IOException { + private void writeJoiner() throws IOException { + if (objectDepth == 2) { + writeExplodedObjectKey(); + } if (isInArray) { - // We need to write the lst field name before writing the value: &key= - if (!isFirstArrayItem) { - writer.write('&'); - writer.write(lastFieldName); - writer.write('='); - } else { - // We just wrote &key= or ?key= for the field name, so we can write the value next. - isFirstArrayItem = false; - } + writeExplodedArrayElementKey(); + } else if (objectDepth == 1) { + // We are encoding a single primitive value, not an element of an array or object + writePrimitiveKey(); + } + } + + /** Write keys for exploded object fields (?field1=value1&field2=value2) */ + private void writeExplodedObjectKey() throws IOException { + if (writeLeadingCharacter) { + writer.write(leadingCharacter); + writeLeadingCharacter = false; + } else { + writer.write('&'); } + + // When writing an object with the 'explode' modifier, we use the field names from the object + // rather than the given parameterName (like we would instead use for arrays). + writer.write(currentField); + writer.write('='); } - private void requireInObject() { - if (!isObjectStarted) { - throw new UnsupportedOperationException( - "Can only form-explode encode top-level key-value objects (e.g. maps but not standalone" - + " primitives or lists)"); + /** Write keys for exploded array elements (?paramName=e1¶mName=e2) */ + private void writeExplodedArrayElementKey() throws IOException { + if (writeLeadingCharacter) { + writer.write(leadingCharacter); + writeLeadingCharacter = false; + } else { + writer.write('&'); } + + // When writing an array with the 'explode' modifier, we repeatedly use the given parameterName + // as keys. + writer.write(parameterName); + writer.write('='); + } + + /** Write the key for the singular primitive value being encoded (?paramName=primitive) */ + private void writePrimitiveKey() throws IOException { + writer.write(leadingCharacter); + writer.write(parameterName); + writer.write('='); } } diff --git a/modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/EncodersTest.java b/modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/EncodersTest.java new file mode 100644 index 0000000..67d7da3 --- /dev/null +++ b/modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/EncodersTest.java @@ -0,0 +1,286 @@ +package io.github.tomboyo.lily.http.encoding; + +import static io.github.tomboyo.lily.http.encoding.Encoders.Modifiers.EXPLODE; +import static io.github.tomboyo.lily.http.encoding.Encoders.form; +import static io.github.tomboyo.lily.http.encoding.Encoders.formContinuation; +import static io.github.tomboyo.lily.http.encoding.Encoders.simple; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.params.provider.Arguments.arguments; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class EncodersTest { + + @Nested + class Simple { + + static Stream simpleSource() { + // arguments: expected, parameter name, object to encode + return Stream.of( + /* + * Primitives + */ + arguments("101", "key", BigInteger.valueOf(101)), + arguments("101", "key", 101L), + arguments("1", "key", 1), + arguments("10.1", "key", BigDecimal.valueOf(10.1)), + arguments("1.2", "key", 1.2d), + arguments("1.2", "key", 1.2f), + arguments("Foo", "key", "Foo"), + // RFC3339 (ISO8601) full-date + arguments("2000-10-01", "key", LocalDate.of(2000, 10, 1)), + // RFC3339 (ISO8601) date-time + arguments( + "2000-10-01T06:30:25.00052Z", + "key", + OffsetDateTime.of(2000, 10, 1, 6, 30, 25, 520_000, ZoneOffset.UTC)), + arguments("false", "key", false), + // Null pointer + arguments("", "key", null), + /* + * Arrays + */ + arguments("123,cats,22.34", "keys", List.of(123, "cats", 22.34)), + arguments("123", "keys", List.of(123)), + // Null pointer + arguments("x,,y", "keys", nullableList("x", null, "y")), + /* + * Objects + */ + arguments("number,5,text,Foo", "keys", new Multiton(5, "Foo")), + arguments("number,7", "keys", new Singleton(7)), + arguments("x,,y,", "keys", nullableOrderedMap("x", null, "y", null))); + } + + @ParameterizedTest + @MethodSource("simpleSource") + void test(String expected, String parameterName, Object obj) { + assertEquals(expected, simple().encode(parameterName, obj)); + } + + @Test + void nestedObjectsInObjects() { + assertThrows( + Exception.class, + () -> simple().encode("keys", Map.of("foo", Map.of("not", "supported")))); + } + + @Test + void nestedObjectsInLists() { + assertThrows( + Exception.class, + () -> simple().encode("keys", List.of("foo", Map.of("not", "supported")))); + } + + @Test + void nestedListsInObjects() { + assertThrows( + Exception.class, + () -> simple().encode("keys", Map.of("foo", List.of("not", "supported")))); + } + + @Test + void nestedListsInLists() { + assertThrows( + Exception.class, + () -> simple().encode("keys", List.of("foo", List.of("not", "supported")))); + } + } + + @Nested + class FormExploded { + static Stream parameters() { + // arguments: expected encoding, parameter name, object to encode. + return Stream.of( + /* + * Primitives + */ + arguments("?key=101", "key", BigInteger.valueOf(101)), + arguments("?key=101", "key", 101L), + arguments("?key=1", "key", 1), + arguments("?key=10.1", "key", BigDecimal.valueOf(10.1)), + arguments("?key=1.2", "key", 1.2d), + arguments("?key=1.2", "key", 1.2f), + arguments("?key=Foo", "key", "Foo"), + // RFC3339 (ISO8601) full-date + arguments("?key=2000-10-01", "key", LocalDate.of(2000, 10, 1)), + // RFC3339 (ISO8601) date-time + arguments( + "?key=2000-10-01T06%3A30%3A25.00052Z", + "key", OffsetDateTime.of(2000, 10, 1, 6, 30, 25, 520_000, ZoneOffset.UTC)), + arguments("?key=false", "key", false), + // Reserved string + arguments("?key%3F=%3F", "key?", "?"), + /* + * Arrays + */ + arguments("?keys=123&keys=cats&keys=22.34", "keys", List.of(123, "cats", 22.34)), + arguments("?keys=123", "keys", List.of(123)), + // Null pointers + arguments("?keys=&keys=", "keys", nullableList(null, null)), + // Reserved string + arguments("?keys%3F=%3F&keys%3F=%3F", "keys?", List.of("?", "?")), + /* + * Objects + */ + arguments("?number=5&text=Foo", "keys", new Multiton(5, "Foo")), + arguments("?number=7", "keys", new Singleton(7)), + // Null pointers + arguments("?foo=&bar=", "keys", nullableOrderedMap("foo", null, "bar", null)), + // Reserved string + arguments("?foo=%3F&bar%3F=%3F", "keys", nullableOrderedMap("foo", "?", "bar?", "?"))); + } + + @ParameterizedTest + @MethodSource("parameters") + void formExplodeTest(String expected, String name, Object obj) { + assertEquals(expected, form(EXPLODE).encode(name, obj)); + } + + @Test + void nestedObjectsInObjects() { + assertThrows( + Exception.class, + () -> form(EXPLODE).encode("param", Map.of("foo", Map.of("not", "supported")))); + } + + @Test + void nestedObjectsInLists() { + assertThrows( + Exception.class, + () -> form(EXPLODE).encode("param", List.of(Map.of("not", "supported")))); + } + + @Test + void nestedListsInLists() { + assertThrows( + Exception.class, + () -> form(EXPLODE).encode("param", List.of(List.of("not", "supported")))); + } + + @Test + void nestedListsInObjects() { + assertThrows( + Exception.class, + () -> form(EXPLODE).encode("param", Map.of("key", List.of("not", "supported")))); + } + } + + @Nested + class FormContinuationExploded { + static Stream parameters() { + // arguments: expected encoding, parameter name, object to encode. + return Stream.of( + /* + * Primitives + */ + arguments("&key=101", "key", BigInteger.valueOf(101)), + arguments("&key=101", "key", 101L), + arguments("&key=1", "key", 1), + arguments("&key=10.1", "key", BigDecimal.valueOf(10.1)), + arguments("&key=1.2", "key", 1.2d), + arguments("&key=1.2", "key", 1.2f), + arguments("&key=Foo", "key", "Foo"), + // RFC3339 (ISO8601) full-date + arguments("&key=2000-10-01", "key", LocalDate.of(2000, 10, 1)), + // RFC3339 (ISO8601) date-time + arguments( + "&key=2000-10-01T06%3A30%3A25.00052Z", + "key", OffsetDateTime.of(2000, 10, 1, 6, 30, 25, 520_000, ZoneOffset.UTC)), + arguments("&key=false", "key", false), + // Reserved string + arguments("&key%3F=%3F", "key?", "?"), + /* + * Arrays + */ + arguments("&keys=123&keys=cats&keys=22.34", "keys", List.of(123, "cats", 22.34)), + arguments("&keys=123", "keys", List.of(123)), + // Null pointers + arguments("&keys=&keys=", "keys", nullableList(null, null)), + // Reserved string + arguments("&keys%3F=%3F&keys%3F=%3F", "keys?", List.of("?", "?")), + /* + * Objects + */ + arguments("&number=5&text=Foo", "keys", new Multiton(5, "Foo")), + arguments("&number=7", "keys", new Singleton(7)), + // Null pointers + arguments("&foo=&bar=", "keys", nullableOrderedMap("foo", null, "bar", null)), + // Reserved string + arguments("&foo=%3F&bar%3F=%3F", "keys", nullableOrderedMap("foo", "?", "bar?", "?"))); + } + + @ParameterizedTest + @MethodSource("parameters") + void formContinuationExplodedTest(String expected, String name, Object obj) { + assertEquals(expected, formContinuation(EXPLODE).encode(name, obj)); + } + + @Test + void nestedObjectsInObjects() { + assertThrows( + Exception.class, + () -> + formContinuation(EXPLODE).encode("param", Map.of("foo", Map.of("not", "supported")))); + } + + @Test + void nestedObjectsInLists() { + assertThrows( + Exception.class, + () -> formContinuation(EXPLODE).encode("param", List.of(Map.of("not", "supported")))); + } + + @Test + void nestedListsInLists() { + assertThrows( + Exception.class, + () -> formContinuation(EXPLODE).encode("param", List.of(List.of("not", "supported")))); + } + + @Test + void nestedListsInObjects() { + assertThrows( + Exception.class, + () -> + formContinuation(EXPLODE) + .encode("param", Map.of("key", List.of("not", "supported")))); + } + } + + private static List nullableList(Object... values) { + return new ArrayList<>(Arrays.asList(values)); + } + + /** Returns a LinkedHashMap which allows null arguments and preserves insertion order. */ + private static LinkedHashMap nullableOrderedMap(Object... objects) { + var map = new LinkedHashMap<>(); + for (int i = 0; i + 1 < objects.length; i += 2) { + map.put(objects[i], objects[i + 1]); + } + return map; + } + + @JsonPropertyOrder({"number", "text"}) + private record Multiton(@JsonProperty("number") int number, @JsonProperty("text") String text) {} + + private record Singleton(@JsonProperty("number") int number) {} +} diff --git a/modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/EncodingTest.java b/modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/EncodingTest.java deleted file mode 100644 index 5895823..0000000 --- a/modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/EncodingTest.java +++ /dev/null @@ -1,185 +0,0 @@ -package io.github.tomboyo.lily.http.encoding; - -import static io.github.tomboyo.lily.http.encoding.Encoding.formExplode; -import static io.github.tomboyo.lily.http.encoding.Encoding.simple; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.params.provider.Arguments.arguments; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.annotation.JsonPropertyOrder; -import com.fasterxml.jackson.core.JsonProcessingException; -import java.math.BigDecimal; -import java.math.BigInteger; -import java.time.LocalDate; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Stream; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -public class EncodingTest { - - @Nested - class Simple { - - static Stream simpleSource() { - return Stream.of( - /* - * Primitives - */ - arguments("101", BigInteger.valueOf(101)), - arguments("101", 101L), - arguments("1", 1), - arguments("10.1", BigDecimal.valueOf(10.1)), - arguments("1.2", 1.2d), - arguments("1.2", 1.2f), - arguments("Foo", "Foo"), - // RFC3339 (ISO8601) full-date - arguments("2000-10-01", LocalDate.of(2000, 10, 1)), - // RFC3339 (ISO8601) date-time - arguments( - "2000-10-01T06:30:25.00052Z", - OffsetDateTime.of(2000, 10, 1, 6, 30, 25, 520_000, ZoneOffset.UTC)), - arguments("false", false), - /* - * Arrays - */ - arguments("123,cats,22.34", List.of(123, "cats", 22.34)), - arguments("123", List.of(123)), - /* - * Objects - */ - arguments("number,5,text,Foo", new Multiton(5, "Foo")), - arguments("number,7", new Singleton(7)), - /* - * Null pointers - */ - arguments("", null), - arguments("x,,y", nullableList("x", null, "y")), - arguments("x,,y,", nullableMap("x", null, "y", null))); - } - - @ParameterizedTest - @MethodSource("simpleSource") - void test(String expected, Object arg) throws JsonProcessingException { - assertEquals(expected, simple(arg)); - } - - @Test - void nestedObjectsInObjects() { - assertThrows( - Exception.class, () -> Encoding.simple(Map.of("foo", Map.of("not", "supported")))); - } - - @Test - void nestedObjectsInLists() { - assertThrows( - Exception.class, () -> Encoding.simple(List.of("foo", Map.of("not", "supported")))); - } - - @Test - void nestedListsInObjects() { - assertThrows( - Exception.class, () -> Encoding.simple(Map.of("foo", List.of("not", "supported")))); - } - - @Test - void nestedListsInLists() { - assertThrows( - Exception.class, () -> Encoding.simple(List.of("foo", List.of("not", "supported")))); - } - } - - @Nested - class FormExplode { - static Stream formExplodeSource() { - /** - * All form style parameters have to have keys, so the only valid arguments are KV stores like - * Maps and Jackson-annotated objectes. - */ - return Stream.of( - /* - * Primitives - */ - arguments("key=101", Map.of("key", BigInteger.valueOf(101))), - arguments("key=101", Map.of("key", 101L)), - arguments("key=1", Map.of("key", 1)), - arguments("key=10.1", Map.of("key", BigDecimal.valueOf(10.1))), - arguments("key=1.2", Map.of("key", 1.2d)), - arguments("key=1.2", Map.of("key", 1.2f)), - arguments("key=Foo", Map.of("key", "Foo")), - // RFC3339 (ISO8601) full-date - arguments("key=2000-10-01", Map.of("key", LocalDate.of(2000, 10, 1))), - // RFC3339 (ISO8601) date-time - arguments( - "key=2000-10-01T06:30:25.00052Z", - Map.of("key", OffsetDateTime.of(2000, 10, 1, 6, 30, 25, 520_000, ZoneOffset.UTC))), - arguments("key=false", Map.of("key", false)), - /* - * Arrays - */ - arguments("key=123&key=cats&key=22.34", Map.of("key", List.of(123, "cats", 22.34))), - arguments("key=123", Map.of("key", List.of(123))), - /* - * Objects - */ - arguments("number=5&text=Foo", new Multiton(5, "Foo")), - arguments("number=7", new Singleton(7)), - /* - * Null pointers - */ - arguments("foo=&bar=", nullableMap("foo", null, "bar", null)), - arguments("key=&key=", Map.of("key", nullableList(null, null)))); - } - - @ParameterizedTest - @MethodSource("formExplodeSource") - void formExplodeTest(String expected, Object arg) throws JsonProcessingException { - assertEquals(expected, formExplode(arg)); - } - - @Test - void nonObjectParameter() { - assertThrows(Exception.class, () -> Encoding.formExplode("not an object")); - } - - @Test - void nestedObjects() { - assertThrows( - Exception.class, () -> Encoding.formExplode(Map.of("foo", Map.of("not", "supported")))); - } - - @Test - void nestedObjectsInLists() { - assertThrows( - Exception.class, - () -> Encoding.formExplode(Map.of("foo", List.of("bar", Map.of("not", "supported"))))); - } - } - - private static List nullableList(Object... values) { - return new ArrayList<>(Arrays.asList(values)); - } - - private static LinkedHashMap nullableMap(Object... objects) { - var map = new LinkedHashMap<>(); - for (int i = 0; i + 1 < objects.length; i += 2) { - map.put(objects[i], objects[i + 1]); - } - return map; - } - - @JsonPropertyOrder({"number", "text"}) - private record Multiton(@JsonProperty("number") int number, @JsonProperty("text") String text) {} - - private record Singleton(@JsonProperty("number") int number) {} -} diff --git a/modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/UriTemplateTest.java b/modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/UriTemplateTest.java index fd246d3..6326a1a 100644 --- a/modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/UriTemplateTest.java +++ b/modules/lily-http/src/test/java/io/github/tomboyo/lily/http/encoding/UriTemplateTest.java @@ -1,40 +1,74 @@ package io.github.tomboyo.lily.http.encoding; -import static io.github.tomboyo.lily.http.encoding.Encoding.simple; +import static io.github.tomboyo.lily.http.encoding.Encoders.Modifiers.EXPLODE; +import static io.github.tomboyo.lily.http.encoding.Encoders.form; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; import io.github.tomboyo.lily.http.UriTemplate; -import io.github.tomboyo.lily.http.UriTemplateException; -import java.net.URI; +import java.util.Map; import org.junit.jupiter.api.Test; public class UriTemplateTest { @Test - public void requiresAllParameters() { - assertThrows( - UriTemplateException.class, - () -> - UriTemplate.forPath("https://example.com/pets/{petId}/foo/{foo}/") - .put("petId", simple(5)) - .toURI()); + void bindInterpolatesGivenStringsExactly() { + var uri = + UriTemplate.of("https://example.com/pets/{petId}/").bind("petId", "?").toURI().toString(); + + assertEquals("https://example.com/pets/?/", uri); } @Test - public void interpolatesParameters() throws Exception { + void bindInterpolatesUsingEncoders() { var uri = - UriTemplate.forPath("https://example.com/pets/{petId}/foo/{foo}") - .put("petId", simple(5)) - .put("foo", simple("f%o/o!")) - .toURI(); + UriTemplate.of("https://example.com/pets/{colors}") + .bind("colors", Map.of("key", "value?"), form(EXPLODE)) + .toURI() + .toString(); - assertEquals(uri, URI.create("https://example.com/pets/5/foo/f%25o%2Fo%21")); + assertEquals("https://example.com/pets/?key=value%3F", uri); } @Test - public void removesExtraneousSlashes() throws Exception { - var uri = UriTemplate.forPath("https://example.com/pets/", "/foo/", "/bar/").toURI(); + void unboundParametersAreLeftBlank() { + var uri = UriTemplate.of("https://example.com/{foo}/{bar}").toURI().toString(); + + assertEquals("https://example.com//", uri); + } + + @Test + void unbindParameters() { + var uri = + UriTemplate.of("https://example.com{foo}") + .bind("foo", "?key=value") + .unbind("foo") + .toURI() + .toString(); + + assertEquals("https://example.com", uri); + } + + @Test + void withTemplate() { + var uri = + UriTemplate.of("https://example.com/{id}") + .bind("id", "1234") + .withTemplate("https://example.com/foo/{id}") + .toURI() + .toString(); + + assertEquals("https://example.com/foo/1234", uri); + } + + @Test + void appendTemplate() { + var uri = + UriTemplate.of("https://example.com/{id}") + .bind("id", "1234") + .appendTemplate("/{queryString}") + .bind("queryString", "?foo=bar") + .toURI() + .toString(); - assertEquals(uri, URI.create("https://example.com/pets/foo/bar")); + assertEquals("https://example.com/1234/?foo=bar", uri); } }