Skip to content

Commit

Permalink
Issues/31 (#36)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Tomboyo authored Oct 9, 2022
1 parent 70f59b8 commit 64a7358
Show file tree
Hide file tree
Showing 12 changed files with 722 additions and 358 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
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;
import com.fasterxml.jackson.databind.ObjectMapper;
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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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}}
Expand All @@ -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;
Expand All @@ -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)
Expand All @@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ void templatesAreExposedForAllOperations() {
.allOperations()
.getPetById()
.uriTemplate()
.put("id", "some-uuid-here")
.bind("id", "some-uuid-here")
.toURI();
"""
.formatted(packageName)),
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <pre>
* 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
* </pre>
*
* @see Encoders
*/
public class UriTemplate {

private final String template;
private final HashMap<String, String> parameters;
private String template;
private final HashMap<String, String> bindings;

private UriTemplate(String template) {
private UriTemplate(String template, HashMap<String, String> 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.
*
* <p>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}"
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit 64a7358

Please sign in to comment.