From 2b94cc1bf8f54f2979194fac1407b1dc56753765 Mon Sep 17 00:00:00 2001 From: Tom Simmons Date: Thu, 9 Feb 2023 21:35:25 -0500 Subject: [PATCH] Issues/60 (#63) configure parameters by location Configure parameters within per-location callbacks/namespaces: ``` myOperation .path(path -> path.id("1234")) .query(query -> query.foo("foo").bar("bar")) .sendSync(); ``` We've also re-assessed the role of UriTemplate as a tool for customizing requests, by which I mean, it is no longer used for that. Instead, the operation exposes `baseUri()`, `pathString()`, and `queryString()` methods that the user can use to create a new URI. This puts more of the customization work back in the native HTTP API. --- .../tomboyo/lily/example/ExampleTest.java | 71 ++++---- .../lily/compiler/cg/AstOperationCodeGen.java | 154 +++++++++++++---- ...rsTest.java => HttpRequestMethodTest.java} | 45 ++--- .../compiler/feature/OperationGroupsTest.java | 7 +- ...FluentApiTest.java => ParametersTest.java} | 157 +++++++++++------- .../feature/SynchronousRequestTests.java | 54 ++++++ .../github/tomboyo/lily/http/UriTemplate.java | 67 ++------ .../lily/http/encoding/UriTemplateTest.java | 43 +---- 8 files changed, 347 insertions(+), 251 deletions(-) rename modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/{HttpRequestBuildersTest.java => HttpRequestMethodTest.java} (64%) rename modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/{FluentApiTest.java => ParametersTest.java} (51%) diff --git a/modules/example/src/test/java/io/github/tomboyo/lily/example/ExampleTest.java b/modules/example/src/test/java/io/github/tomboyo/lily/example/ExampleTest.java index 6b4b283..81e277a 100644 --- a/modules/example/src/test/java/io/github/tomboyo/lily/example/ExampleTest.java +++ b/modules/example/src/test/java/io/github/tomboyo/lily/example/ExampleTest.java @@ -3,19 +3,15 @@ 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.formExploded; -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.fail; -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.example.showpetbyidoperation.ShowPetById200; import io.github.tomboyo.lily.example.showpetbyidoperation.ShowPetByIdDefault; +import java.net.URI; import java.net.http.HttpRequest; -import java.net.http.HttpResponse.BodyHandlers; -import java.util.Map; import org.junit.jupiter.api.Test; /* These are examples of how to use the API, formulated as contrived unit tests. These are not actual tests. */ @@ -47,10 +43,14 @@ void happyPath(WireMockRuntimeInfo info) throws Exception { * fluently specify path and query parameters (not yet header or body parameters). */ var response = - api.petsOperations() // All operations with the `pets` tag. (see also: everyOperation) - .showPetById() // The GET /pets/{petId} operation - .setPetId("1234") // bind "1234" to the {petId} parameter of the OAS operation - .sendSync(); // execute the request synchronously and get a ShowPetByIdResponse object. + // All operations with the `pets` tag. (see also: everyOperation) + api.petsOperations() + // The GET /pets/{petId} operation + .showPetById() + // bind "1234" to the {petId} path parameter of the OAS operation. + .path(path -> path.petId("1234")) + // execute the request synchronously and get a ShowPetByIdResponse object. + .sendSync(); /* The response object is a sealed interface based on what the OAS says the API can return. In this case, the * ShowPetByIdResponse may consist of the 200 and Default variants. @@ -80,40 +80,33 @@ void manual(WireMockRuntimeInfo info) throws Exception { {"id": 1234, "name": "Reginald"} """))); - /* - * All operations (GET /foo, POST /foo, etc) documented by the OAS are captured by the generated API, named - * according to their OAS operation IDs. - */ var api = Api.newBuilder().uri(info.getHttpBaseUrl()).build(); - var operation = - api - // All operations with the `pets` tag. (We could also use .everyOperation()) - .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 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"), formExploded()) - .toURI(); + /* Customize a request with any parameters documented by the OpenAPI specification. In this + * example we'll assume query parameters are missing from the OpenAPI specification and can't + * be configured via the generated API. + */ + var operation = api.petsOperations().showPetById().path(path -> path.petId("1234")); - /* - * 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. + /* Using the native API, create a new http request. It will use our templated request for + * default values, but we can override any part of the request, like the query string. */ - var response = - api.httpClient() - .send(HttpRequest.newBuilder().GET().uri(uri).build(), BodyHandlers.ofInputStream()); + var request = + HttpRequest.newBuilder(operation.httpRequest(), (k, v) -> true) + // We can use baseUri(), pathString(), and queryString() from the operation to override + // templated URIs. This lets us work around incomplete specifications until they are + // fixed. + .uri(URI.create(operation.baseUri() + operation.pathString() + "?foo=foo&bar=bar")) + .build(); - assertEquals(200, response.statusCode()); - assertEquals( - new Pet(1234L, "Reginald", null), new ObjectMapper().readValue(response.body(), Pet.class)); + /* Dispatch the customized request, taking advantage of the same sendSync behavior we normally + * would. + */ + var response = operation.sendSync(request); + if (response instanceof ShowPetById200 ok) { + assertEquals(new Pet(1234L, "Reginald", null), ok.body()); + } else { + fail("Expected a 200 response"); + } } } diff --git a/modules/lily-compiler/src/main/java/io/github/tomboyo/lily/compiler/cg/AstOperationCodeGen.java b/modules/lily-compiler/src/main/java/io/github/tomboyo/lily/compiler/cg/AstOperationCodeGen.java index 1b1aba4..bff1d8e 100644 --- a/modules/lily-compiler/src/main/java/io/github/tomboyo/lily/compiler/cg/AstOperationCodeGen.java +++ b/modules/lily-compiler/src/main/java/io/github/tomboyo/lily/compiler/cg/AstOperationCodeGen.java @@ -18,41 +18,73 @@ public static Source renderAstOperation(AstOperation ast) { """ package {{packageName}}; public class {{className}} { - private final io.github.tomboyo.lily.http.UriTemplate uriTemplate; + private final String baseUri; + private final io.github.tomboyo.lily.http.UriTemplate pathTemplate; + private final io.github.tomboyo.lily.http.UriTemplate queryTemplate; private final java.net.http.HttpClient httpClient; private final com.fasterxml.jackson.databind.ObjectMapper objectMapper; + private Query query; + private Path path; + public {{className}}( - String uri, + String baseUri, java.net.http.HttpClient httpClient, com.fasterxml.jackson.databind.ObjectMapper objectMapper) { // We assume uri is non-null and ends with a trailing '/'. - this.uriTemplate = io.github.tomboyo.lily.http.UriTemplate.of(uri + "{{{relativePath}}}{{{queryTemplate}}}"); + this.baseUri = baseUri; + this.pathTemplate = io.github.tomboyo.lily.http.UriTemplate.of("{{{pathTemplate}}}"); + this.queryTemplate = io.github.tomboyo.lily.http.UriTemplate.of("{{{queryTemplate}}}"); this.httpClient = httpClient; this.objectMapper = objectMapper; + + path = new Path(); + query = new Query(); } - {{#urlParameters}} - private {{{fqpt}}} {{name}}; - public {{className}} {{name}}({{{fqpt}}} {{name}}) { - this.{{name}} = {{name}}; + /** Configure path parameters for this operation, if any. */ + public {{className}} path(java.util.function.Function path) { + this.path = path.apply(this.path); return this; } - {{/urlParameters}} - - public io.github.tomboyo.lily.http.UriTemplate uriTemplate() { - {{#smartFormEncoder}} - var smartFormEncoder = io.github.tomboyo.lily.http.encoding.Encoders.smartFormExploded(); // stateful - {{/smartFormEncoder}} - {{#urlParameters}} - if (this.{{name}} != null) { - uriTemplate.bind( - "{{apiName}}", - this.{{name}}, - {{{encoder}}}); - } - {{/urlParameters}} - return uriTemplate; + + /** Configure query parameters for this operation, if any. */ + public {{className}} query(java.util.function.Function query) { + this.query = query.apply(this.query); + return this; + } + + /** Get the base URI of the service (like {@code "https://example.com/"}). It always + * ends with a trailing slash. + */ + public String baseUri() { + return this.baseUri; + } + + /** Get this operation's relative path interpolated with any bound parameters. The + * path is always relative, so it does not start with a "/". + */ + public String pathString() { + {{#pathSmartFormEncoder}} + var smartFormEncoder = io.github.tomboyo.lily.http.encoding.Encoders.smartFormExploded(); + {{/pathSmartFormEncoder}} + return this.pathTemplate + {{#pathParameters}} + .bind("{{apiName}}", this.path.{{name}}, {{{encoder}}}) + {{/pathParameters}} + .toString(); + } + + /** Get the query string for this operation and any bound parameters. */ + public String queryString() { + {{#querySmartFormEncoder}} + var smartFormEncoder = io.github.tomboyo.lily.http.encoding.Encoders.smartFormExploded(); + {{/querySmartFormEncoder}} + return this.queryTemplate + {{#queryParameters}} + .bind("{{apiName}}", this.query.{{name}}, {{{encoder}}}) + {{/queryParameters}} + .toString(); } /** @@ -62,7 +94,7 @@ public io.github.tomboyo.lily.http.UriTemplate uriTemplate() { */ public java.net.http.HttpRequest httpRequest() { return java.net.http.HttpRequest.newBuilder() - .uri(uriTemplate().toURI()) + .uri(java.net.URI.create(this.baseUri + pathString() + queryString())) .method("{{method}}", java.net.http.HttpRequest.BodyPublishers.noBody()) .build(); } @@ -71,11 +103,48 @@ public java.net.http.HttpRequest httpRequest() { * Synchronously perform the HTTP request for this operation. */ public {{{responseTypeName}}} sendSync() throws java.io.IOException, InterruptedException { + return sendSync(httpRequest()); + } + + /** + * Synchronously perform the HTTP request for a custom HttpRequest. You will typically + * only use this API when the underlying OpenAPI specification is missing parameters + * or other necessary components. Use the {@link #httpRequest()} method to get a + * template HTTP request from this operation, customize it with + * {@link java.net.http.HttpRequest#newBuilder(java.net.http.HttpRequest, java.util.function.BiPredicate)}, + * then use this method to dispatch it. + */ + public {{{responseTypeName}}} sendSync(java.net.http.HttpRequest request) + throws java.io.IOException, InterruptedException { var httpResponse = this.httpClient.send( - httpRequest(), + request, java.net.http.HttpResponse.BodyHandlers.ofInputStream()); return {{{responseTypeName}}}.fromHttpResponse(httpResponse, objectMapper); } + + public static class Path { + private Path() {} + + {{#pathParameters}} + private {{{fqpt}}} {{name}}; + public Path {{name}}({{{fqpt}}} {{name}}) { + this.{{name}} = {{name}}; + return this; + } + {{/pathParameters}} + } + + public static class Query { + private Query() {} + + {{#queryParameters}} + private {{{fqpt}}} {{name}}; + public Query {{name}}({{{fqpt}}} {{name}}) { + this.{{name}} = {{name}}; + return this; + } + {{/queryParameters}} + } } """, "renderAstOperation", @@ -84,27 +153,44 @@ public java.net.http.HttpRequest httpRequest() { ast.name().packageName(), "className", ast.name().typeName(), - "relativePath", + "pathTemplate", withoutLeadingSlash(ast.relativePath()), - "method", - ast.method(), "queryTemplate", ast.parameters().stream() .filter(parameter -> parameter.location() == QUERY) .map(parameter -> "{" + parameter.apiName() + "}") .collect(Collectors.joining("")), - "smartFormEncoder", - ast.parameters().stream().anyMatch(parameter -> parameter.location() == QUERY), - // path and query parameters -- anything in the URL itself - "urlParameters", + "method", + ast.method(), + "pathSmartFormEncoder", + ast.parameters().stream() + .anyMatch( + parameter -> + parameter.location() == PATH && parameter.encoding().style() == FORM), + "querySmartFormEncoder", + ast.parameters().stream() + .anyMatch( + parameter -> + parameter.location() == QUERY && parameter.encoding().style() == FORM), + "pathParameters", + ast.parameters().stream() + .filter(parameter -> parameter.location() == PATH) + .map( + parameter -> + Map.of( + "fqpt", parameter.typeName().toFqpString(), + "name", parameter.name().lowerCamelCase(), + "apiName", parameter.apiName(), + "encoder", getEncoder(parameter.encoding()))) + .collect(toList()), + "queryParameters", ast.parameters().stream() - .filter( - parameter -> parameter.location() == PATH || parameter.location() == QUERY) + .filter(parameter -> parameter.location() == QUERY) .map( parameter -> Map.of( "fqpt", parameter.typeName().toFqpString(), - "name", "set" + parameter.name().upperCamelCase(), + "name", parameter.name().lowerCamelCase(), "apiName", parameter.apiName(), "encoder", getEncoder(parameter.encoding()))) .collect(toList()), diff --git a/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/HttpRequestBuildersTest.java b/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/HttpRequestMethodTest.java similarity index 64% rename from modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/HttpRequestBuildersTest.java rename to modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/HttpRequestMethodTest.java index eab997b..bd0a28c 100644 --- a/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/HttpRequestBuildersTest.java +++ b/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/HttpRequestMethodTest.java @@ -10,7 +10,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; -public class HttpRequestBuildersTest { +public class HttpRequestMethodTest { private static String packageName; private static HttpRequest actual; @@ -27,21 +27,16 @@ static void beforeAll() throws Exception { """ openapi: 3.0.2 paths: - /foo/{id}: + /foo: + get: + operationId: get-foo post: - operationId: postFoo - parameters: - - name: id - in: path - required: true - schema: - type: string - - name: color - in: query - schema: - type: string + operationId: post-foo """); + } + @Test + void postFoo() { actual = evaluate( """ @@ -50,21 +45,29 @@ static void beforeAll() throws Exception { .build() .everyOperation() .postFoo() - .setId("1234") - .setColor("red") .httpRequest(); """ .formatted(packageName), HttpRequest.class); - } - @Test - void templatesUriWithPathAndQueryParameters() { - assertEquals("https://example.com/foo/1234?color=red", actual.uri().toString()); + assertEquals("POST", actual.method()); } @Test - void templatesHttpMethod() { - assertEquals("POST", actual.method()); + void getFoo() { + actual = + evaluate( + """ + return %s.Api.newBuilder() + .uri("https://example.com/") + .build() + .everyOperation() + .getFoo() + .httpRequest(); + """ + .formatted(packageName), + HttpRequest.class); + + assertEquals("GET", actual.method()); } } diff --git a/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/OperationGroupsTest.java b/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/OperationGroupsTest.java index fbbf198..761baeb 100644 --- a/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/OperationGroupsTest.java +++ b/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/OperationGroupsTest.java @@ -3,12 +3,13 @@ import static io.github.tomboyo.lily.compiler.CompilerSupport.compileOas; import static io.github.tomboyo.lily.compiler.CompilerSupport.deleteGeneratedSources; import static io.github.tomboyo.lily.compiler.CompilerSupport.evaluate; +import static java.util.stream.Collectors.toSet; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.lang.reflect.Method; import java.util.Arrays; -import java.util.List; +import java.util.Set; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -122,8 +123,8 @@ void everyOperation() { .getDeclaredMethods(); assertEquals( - List.of("getPet", "postPet", "putPet"), - Arrays.stream(methods).map(Method::getName).toList(), + Set.of("getPet", "postPet", "putPet"), + Arrays.stream(methods).map(Method::getName).collect(toSet()), "All operations are part of the everyOperation() group"); } diff --git a/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/FluentApiTest.java b/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/ParametersTest.java similarity index 51% rename from modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/FluentApiTest.java rename to modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/ParametersTest.java index 2be9d39..2210119 100644 --- a/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/FluentApiTest.java +++ b/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/ParametersTest.java @@ -5,6 +5,7 @@ import static io.github.tomboyo.lily.compiler.CompilerSupport.evaluate; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; import java.net.URI; import org.junit.jupiter.api.AfterAll; @@ -12,20 +13,44 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -/** The fluent API supports chaining calls to prepare and execute requests. */ -public class FluentApiTest { +/** + * Operations expose setters by parameter location, since parameters are only distinct by name and + * location. Values are bound to parameters using anonymous namespaces to leverge IDE support and + * avoid making the user import several classes: + * + *
{@code
+ * myOperation
+ *   .path(path ->
+ *       path.foo("1234")
+ *           .bar("5678"))
+ *   .query(query ->
+ *       query.foo("abcd"))
+ * }
+ * + *

Operations also expose {@code pathString()} and {@code queryString()} getters so that the user + * can reuse these interpolated request components while customizing http requests with the native + * API, such as when working around OpenAPI specification flaws: + * + *

{@code
+ * var operation = myOperation
+ *   .path(path -> path.id("id"))
+ *   .query(query -> query.include(List.of("a", "b")));
+ * var request = HttpRequest.newBuilder(
+ *     operation.httpRequest(),
+ *     (k, v) -> true)
+ *   .uri("https://example/com/foo/" + operation.pathString() + operation.queryString())
+ *   .build();
+ * }
+ */ +public class ParametersTest { @AfterAll static void afterAll() throws Exception { deleteGeneratedSources(); } - /** - * To facilitate temporary work-arounds, users can access the underlying UriTemplate from - * operations. This allows users to override and otherwise customize request paths and queries. - */ @Nested - class ExposesUnderlyingUriTemplates { + class PathParameters { private static String packageName; @BeforeAll @@ -35,11 +60,16 @@ static void beforeAll() throws Exception { """ openapi: 3.0.2 paths: - /pets/{id}: + /pets/{kennelId}/{petId}: get: - operationId: getPetById + operationId: getPetFromKennel parameters: - - name: id + - name: kennelId + in: path + required: true + schema: + type: string + - name: petId in: path required: true schema: @@ -48,51 +78,31 @@ static void beforeAll() throws Exception { } @Test - void templatesAreExposedForAllOperations() { - assertThat( - "Operations' URI templates may be used to create complete paths to a resource", + void path() { + var actual = evaluate( """ return %s.Api.newBuilder() .uri("https://example.com/") .build() .everyOperation() - .getPetById() - .uriTemplate() - .bind("id", "some-uuid-here") - .toURI(); + .getPetFromKennel() + .path(path -> + path.kennelId("kennelId") + .petId("petId")) + .httpRequest() + .uri(); """ - .formatted(packageName)), - is(URI.create("https://example.com/pets/some-uuid-here"))); - } - } - - /** Lily generates fluent-style setters for all path parameters. */ - @Nested - class HasFluentPathParameters { - private static String packageName; - - @BeforeAll - static void beforeAll() throws Exception { - packageName = - compileOas( - """ - openapi: 3.0.2 - paths: - /pets/{id}: - get: - operationId: getPetById - parameters: - - name: id - in: path - required: true - schema: - type: string - """); + .formatted(packageName), + URI.class); + assertThat( + "Named path parameters are bound by the path method", + actual, + is(URI.create("https://example.com/pets/kennelId/petId"))); } @Test - void hasPathParameterSetters() { + void pathString() { var actual = evaluate( """ @@ -100,23 +110,23 @@ void hasPathParameterSetters() { .uri("https://example.com/") .build() .everyOperation() - .getPetById() - .setId("1234") - .uriTemplate() - .toURI(); + .getPetFromKennel() + .path(path -> + path.kennelId("kennelId") + .petId("petId")) + .pathString(); """ .formatted(packageName), - URI.class); - assertThat( - "Named path parameters may be set via the operation API", + String.class); + assertEquals( + "pets/kennelId/petId", actual, - is(URI.create("https://example.com/pets/1234"))); + "pathString() returns the interpolated path part of the configured operation"); } } - /** Lily generates fluent-style setters for all query parameters. */ @Nested - class HasFluentQueryParameters { + class QueryParameters { private static String packageName; @BeforeAll @@ -150,7 +160,7 @@ static void beforeAll() throws Exception { } @Test - void hasQueryParameterSetters() { + void query() { var actual = evaluate( """ @@ -159,17 +169,42 @@ void hasQueryParameterSetters() { .build() .everyOperation() .listPets() - .setLimit(5) - .setInclude(java.util.List.of("name", "age")) - .uriTemplate() - .toURI(); + .query(query -> query + .limit(5) + .include(java.util.List.of("name", "age"))) + .httpRequest() + .uri(); """ .formatted(packageName), URI.class); assertThat( - "Query parameters may be set via the operation API", + "Query parameters are bound by the query method", actual, is(URI.create("https://example.com/pets?include=name&include=age&limit=5"))); } + + @Test + void queryString() { + var actual = + evaluate( + """ + return %s.Api.newBuilder() + .uri("https://example.com/") + .build() + .everyOperation() + .listPets() + .query(query -> query + .limit(5) + .include(java.util.List.of("name", "age"))) + .queryString(); + """ + .formatted(packageName), + String.class); + + assertEquals( + "?include=name&include=age&limit=5", + actual, + "queryString() returns the interpolated query string of the configured operation"); + } } } diff --git a/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/SynchronousRequestTests.java b/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/SynchronousRequestTests.java index 553583c..37c11bf 100644 --- a/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/SynchronousRequestTests.java +++ b/modules/lily-compiler/src/test/java/io/github/tomboyo/lily/compiler/feature/SynchronousRequestTests.java @@ -16,6 +16,7 @@ import java.io.InputStream; import java.lang.reflect.InvocationTargetException; import java.net.http.HttpResponse; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -23,6 +24,11 @@ @WireMockTest class SynchronousRequestTests { + @AfterAll + static void afterAll() throws Exception { + // deleteGeneratedSources(); + } + @Nested class WhenResponseHasContent { private static String packageName; @@ -290,4 +296,52 @@ void usesDefault(WireMockRuntimeInfo info) throws Exception { + " unexpected responses"); } } + + /* + * After a user customizes an HTTP request, they can still use the operation's sendSync method to + * dispatch the request and deserialize the response into a Response object. + */ + @Test + void customizedRequest(WireMockRuntimeInfo info) throws Exception { + var packageName = + compileOas( + """ + openapi: 3.0.2 + paths: + /pets: + get: + operationId: listPets + responses: + '200': + content: + 'application/json': + schema: + type: string + """); + + stubFor(get("/pets").willReturn(ok("\"expected\""))); + + var actual = + evaluate( + """ + var operation = %s.Api.newBuilder() + .uri("%s") + .build() + .everyOperation() + .listPets(); + var response = operation.sendSync( + operation.httpRequest()); + if (response instanceof %s.listpetsoperation.ListPets200 ok) { + return ok.body(); + } + throw new RuntimeException("Expected 200 OK response"); + """ + .formatted(packageName, info.getHttpBaseUrl(), packageName), + String.class); + + assertEquals( + "expected", + actual, + "The user may customize an http request and send it with the operation's sendSync method"); + } } 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 bcb47c4..a94950d 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 @@ -4,12 +4,12 @@ import io.github.tomboyo.lily.http.encoding.Encoder; import io.github.tomboyo.lily.http.encoding.Encoders; -import java.net.URI; import java.util.HashMap; import java.util.regex.Pattern; /** - * A utility that creates URIs from template strings and parameter bindings. + * A utility that creates strings from templates and parameter bindings, where all bindings are URL- + * encoded. * *
{@code
  * UriTemplate
@@ -17,7 +17,6 @@
  *   .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
  * }
@@ -26,7 +25,7 @@ */ public class UriTemplate { - private String template; + private final String template; private final HashMap bindings; private UriTemplate(String template, HashMap bindings) { @@ -40,34 +39,10 @@ private UriTemplate(String template, HashMap bindings) { * @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<>()); } - /** - * 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; - } - - /** - * 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. * @@ -101,34 +76,22 @@ public UriTemplate bind(String parameter, Object value, Encoder encoder) { } /** - * 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 the finished URI from the template and bound parameters. + * Create teh interpolated string from the template and bound parameters. * *

Empty parameters are encoded as empty strings. * - * @return The finished URI. + * @return The interpolated string. */ - public URI toURI() { + @Override + public String toString() { var pattern = Pattern.compile("\\{([^{}]+)}"); // "{parameterName}" - var uri = - pattern - .matcher(template) - .replaceAll( - (matchResult -> { - var name = template.substring(matchResult.start() + 1, matchResult.end() - 1); - var value = bindings.get(name); - return requireNonNullElse(value, ""); - })); - return URI.create(uri); + return pattern + .matcher(template) + .replaceAll( + (matchResult -> { + var name = template.substring(matchResult.start() + 1, matchResult.end() - 1); + var value = bindings.get(name); + return requireNonNullElse(value, ""); + })); } } 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 41c2e91..aded737 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 @@ -10,8 +10,7 @@ public class UriTemplateTest { @Test void bindInterpolatesGivenStringsExactly() { - var uri = - UriTemplate.of("https://example.com/pets/{petId}/").bind("petId", "?").toURI().toString(); + var uri = UriTemplate.of("https://example.com/pets/{petId}/").bind("petId", "?").toString(); assertEquals("https://example.com/pets/?/", uri); } @@ -21,7 +20,6 @@ void bindInterpolatesUsingEncoders() { var uri = UriTemplate.of("https://example.com/pets/{colors}") .bind("colors", Map.of("key", "value?"), formExploded()) - .toURI() .toString(); assertEquals("https://example.com/pets/?key=value%3F", uri); @@ -29,45 +27,8 @@ void bindInterpolatesUsingEncoders() { @Test void unboundParametersAreLeftBlank() { - var uri = UriTemplate.of("https://example.com/{foo}/{bar}").toURI().toString(); + var uri = UriTemplate.of("https://example.com/{foo}/{bar}").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("https://example.com/1234/?foo=bar", uri); - } }