From d4f0b3c116998f268d54c9616e17c8b79a213ba1 Mon Sep 17 00:00:00 2001 From: Jianghao Lu Date: Wed, 23 Jun 2021 18:12:05 -0700 Subject: [PATCH] Add RequestOptions & handling in RestProxy (#22334) * Add RequestOptions & handling in RestProxy * Handle BinaryData request & response types * Address some feedback * Move RequestOptions * Change ResponseStatusOption to boolean * Fix issues from cr feedback * Move javadoc samples * Fix copy paste error in javadocs * Fix swagger method parser test * Fix test broken by encoding * Fix checkstyle error Co-authored-by: Jianghao Lu --- sdk/core/azure-core/pom.xml | 6 + .../azure/core/http/rest/RequestOptions.java | 177 ++++++++++++++++++ .../com/azure/core/http/rest/RestProxy.java | 30 ++- .../core/http/rest/SwaggerMethodParser.java | 10 + .../azure-core/src/main/java/module-info.java | 1 + .../RequestOptionsJavaDocCodeSnippets.java | 76 ++++++++ .../core/http/rest/RequestOptionsTests.java | 79 ++++++++ .../http/rest/SwaggerMethodParserTests.java | 35 ++++ 8 files changed, 406 insertions(+), 8 deletions(-) create mode 100644 sdk/core/azure-core/src/main/java/com/azure/core/http/rest/RequestOptions.java create mode 100644 sdk/core/azure-core/src/samples/java/com/azure/core/http/rest/RequestOptionsJavaDocCodeSnippets.java create mode 100644 sdk/core/azure-core/src/test/java/com/azure/core/http/rest/RequestOptionsTests.java diff --git a/sdk/core/azure-core/pom.xml b/sdk/core/azure-core/pom.xml index 9576ce59ab03a..5fd3dc37a9612 100644 --- a/sdk/core/azure-core/pom.xml +++ b/sdk/core/azure-core/pom.xml @@ -162,6 +162,12 @@ 1.22 test + + javax.json + javax.json-api + 1.1.4 + test + diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/RequestOptions.java b/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/RequestOptions.java new file mode 100644 index 0000000000000..9ea4770041051 --- /dev/null +++ b/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/RequestOptions.java @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.http.rest; + +import com.azure.core.annotation.QueryParam; +import com.azure.core.http.HttpHeader; +import com.azure.core.http.HttpRequest; +import com.azure.core.util.BinaryData; + +import java.util.function.Consumer; + +/** + * This class contains the options to customize a HTTP request. {@link RequestOptions} can be + * used to configure the request headers, query params, the request body, or add a callback + * to modify all aspects of the HTTP request. + * + *

+ * An instance of fully configured {@link RequestOptions} can be passed to a service method that + * preconfigures known components of the request like URL, path params etc, further modifying both + * un-configured, or preconfigured components. + *

+ * + *

+ * To demonstrate how this class can be used to construct a request, let's use a Pet Store service as an example. The + * list of APIs available on this service are documented in the swagger definition. + *

+ * + *

Creating an instance of RequestOptions

+ * {@codesnippet com.azure.core.http.rest.requestoptions.instantiation} + * + *

Configuring the request with JSON body and making a HTTP POST request

+ * To add a new pet to the pet store, a HTTP POST call should + * be made to the service with the details of the pet that is to be added. The details of the pet are included as the + * request body in JSON format. + * + * The JSON structure for the request is defined as follows: + *
{@code
+ * {
+ *   "id": 0,
+ *   "category": {
+ *     "id": 0,
+ *     "name": "string"
+ *   },
+ *   "name": "doggie",
+ *   "photoUrls": [
+ *     "string"
+ *   ],
+ *   "tags": [
+ *     {
+ *       "id": 0,
+ *       "name": "string"
+ *     }
+ *   ],
+ *   "status": "available"
+ * }
+ * }
+ * + * To create a concrete request, Json builder provided in javax package is used here for demonstration. However, any + * other Json building library can be used to achieve similar results. + * + * {@codesnippet com.azure.core.http.rest.requestoptions.createjsonrequest} + * + * Now, this string representation of the JSON request can be set as body of RequestOptions + * + * {@codesnippet com.azure.core.http.rest.requestoptions.postrequest} + */ +public final class RequestOptions { + private Consumer requestCallback = request -> { }; + private boolean throwOnError = true; + private BinaryData requestBody; + + /** + * Gets the request callback, applying all the configurations set on this RequestOptions. + * @return the request callback + */ + Consumer getRequestCallback() { + return this.requestCallback; + } + + /** + * Gets whether or not to throw an exception when an HTTP response with a status code indicating an error + * (400 or above) is received. + * + * @return true if to throw on status codes of 400 or above, false if not. Default is true. + */ + boolean isThrowOnError() { + return this.throwOnError; + } + + /** + * Adds a header to the HTTP request. + * @param header the header key + * @param value the header value + * + * @return the modified RequestOptions object + */ + public RequestOptions addHeader(String header, String value) { + this.requestCallback = this.requestCallback.andThen(request -> { + HttpHeader httpHeader = request.getHeaders().get(header); + if (httpHeader == null) { + request.getHeaders().set(header, value); + } else { + httpHeader.addValue(value); + } + }); + return this; + } + + /** + * Adds a query parameter to the request URL. The parameter name and value will be URL encoded. + * To use an already encoded parameter name and value, call {@code addQueryParam("name", "value", true)}. + * + * @param parameterName the name of the query parameter + * @param value the value of the query parameter + * @return the modified RequestOptions object + */ + public RequestOptions addQueryParam(String parameterName, String value) { + return addQueryParam(parameterName, value, false); + } + + /** + * Adds a query parameter to the request URL, specifying whether the parameter is already encoded. + * A value true for this argument indicates that value of {@link QueryParam#value()} is already encoded + * hence engine should not encode it, by default value will be encoded. + * + * @param parameterName the name of the query parameter + * @param value the value of the query parameter + * @param encoded whether or not this query parameter is already encoded + * @return the modified RequestOptions object + */ + public RequestOptions addQueryParam(String parameterName, String value, boolean encoded) { + this.requestCallback = this.requestCallback.andThen(request -> { + String url = request.getUrl().toString(); + String encodedParameterName = encoded ? parameterName : UrlEscapers.QUERY_ESCAPER.escape(parameterName); + String encodedParameterValue = encoded ? value : UrlEscapers.QUERY_ESCAPER.escape(value); + request.setUrl(url + (url.contains("?") ? "&" : "?") + encodedParameterName + "=" + encodedParameterValue); + }); + return this; + } + + /** + * Adds a custom request callback to modify the HTTP request before it's sent by the HttpClient. + * The modifications made on a RequestOptions object is applied in order on the request. + * + * @param requestCallback the request callback + * @return the modified RequestOptions object + */ + public RequestOptions addRequestCallback(Consumer requestCallback) { + this.requestCallback = this.requestCallback.andThen(requestCallback); + return this; + } + + /** + * Sets the body to send as part of the HTTP request. + * @param requestBody the request body data + * @return the modified RequestOptions object + */ + public RequestOptions setBody(BinaryData requestBody) { + this.requestCallback = this.requestCallback.andThen(request -> { + request.setBody(requestBody.toBytes()); + }); + return this; + } + + /** + * Sets whether or not to throw an exception when an HTTP response with a status code indicating an error + * (400 or above) is received. By default an exception will be thrown when an error response is received. + * + * @param throwOnError true if to throw on status codes of 400 or above, false if not. Default is true. + * @return the modified RequestOptions object + */ + public RequestOptions setThrowOnError(boolean throwOnError) { + this.throwOnError = throwOnError; + return this; + } +} diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/RestProxy.java b/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/RestProxy.java index 4e1bf7b482c37..e52deecbc1cb4 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/RestProxy.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/RestProxy.java @@ -23,6 +23,7 @@ import com.azure.core.implementation.serializer.HttpResponseDecoder; import com.azure.core.implementation.serializer.HttpResponseDecoder.HttpDecodedResponse; import com.azure.core.util.Base64Url; +import com.azure.core.util.BinaryData; import com.azure.core.util.Context; import com.azure.core.util.FluxUtil; import com.azure.core.util.UrlBuilder; @@ -134,11 +135,17 @@ public Object invoke(Object proxy, final Method method, Object[] args) { request.setBody(validateLength(request)); } + RequestOptions options = methodParser.setRequestOptions(args); + if (options != null) { + options.getRequestCallback().accept(request); + } + final Mono asyncResponse = send(request, context); Mono asyncDecodedResponse = this.decoder.decode(asyncResponse, methodParser); - return handleRestReturnType(asyncDecodedResponse, methodParser, methodParser.getReturnType(), context); + return handleRestReturnType(asyncDecodedResponse, methodParser, + methodParser.getReturnType(), context, options); } catch (IOException e) { throw logger.logExceptionAsError(Exceptions.propagate(e)); } @@ -286,7 +293,9 @@ private HttpRequest configRequest(final HttpRequest request, final SwaggerMethod } } - if (isJson) { + if (bodyContentObject instanceof BinaryData) { + request.setBody(((BinaryData) bodyContentObject).toBytes()); + } else if (isJson) { ByteArrayOutputStream stream = new AccessibleByteArrayOutputStream(); serializer.serialize(bodyContentObject, SerializerEncoding.JSON, stream); @@ -318,9 +327,9 @@ private HttpRequest configRequest(final HttpRequest request, final SwaggerMethod } private Mono ensureExpectedStatus(final Mono asyncDecodedResponse, - final SwaggerMethodParser methodParser) { + final SwaggerMethodParser methodParser, RequestOptions options) { return asyncDecodedResponse - .flatMap(decodedHttpResponse -> ensureExpectedStatus(decodedHttpResponse, methodParser)); + .flatMap(decodedHttpResponse -> ensureExpectedStatus(decodedHttpResponse, methodParser, options)); } private static Exception instantiateUnexpectedException(final UnexpectedExceptionInformation exception, @@ -365,10 +374,11 @@ private static Exception instantiateUnexpectedException(final UnexpectedExceptio * @return An async-version of the provided decodedResponse. */ private Mono ensureExpectedStatus(final HttpDecodedResponse decodedResponse, - final SwaggerMethodParser methodParser) { + final SwaggerMethodParser methodParser, RequestOptions options) { final int responseStatusCode = decodedResponse.getSourceResponse().getStatusCode(); final Mono asyncResult; - if (!methodParser.isExpectedResponseStatusCode(responseStatusCode)) { + if (!methodParser.isExpectedResponseStatusCode(responseStatusCode) + && (options == null || options.isThrowOnError())) { Mono bodyAsBytes = decodedResponse.getSourceResponse().getBodyAsByteArray(); asyncResult = bodyAsBytes.flatMap((Function>) responseContent -> { @@ -471,6 +481,9 @@ private Mono handleBodyReturnType(final HttpDecodedResponse response, } else if (FluxUtil.isFluxByteBuffer(entityType)) { // Mono> asyncResult = Mono.just(response.getSourceResponse().getBody()); + } else if (TypeUtil.isTypeOrSubTypeOf(entityType, BinaryData.class)) { + // Mono + asyncResult = BinaryData.fromFlux(response.getSourceResponse().getBody()); } else { // Mono or Mono> asyncResult = response.getDecodedBody((byte[]) null); @@ -490,9 +503,10 @@ private Mono handleBodyReturnType(final HttpDecodedResponse response, private Object handleRestReturnType(final Mono asyncHttpDecodedResponse, final SwaggerMethodParser methodParser, final Type returnType, - final Context context) { + final Context context, + final RequestOptions options) { final Mono asyncExpectedResponse = - ensureExpectedStatus(asyncHttpDecodedResponse, methodParser) + ensureExpectedStatus(asyncHttpDecodedResponse, methodParser, options) .doOnEach(RestProxy::endTracingSpan) .contextWrite(reactor.util.context.Context.of("TRACING_CONTEXT", context)); diff --git a/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/SwaggerMethodParser.java b/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/SwaggerMethodParser.java index 4a8fee390c050..ba8c9c0085dcf 100644 --- a/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/SwaggerMethodParser.java +++ b/sdk/core/azure-core/src/main/java/com/azure/core/http/rest/SwaggerMethodParser.java @@ -347,6 +347,16 @@ public Context setContext(Object[] swaggerMethodArguments) { return (context != null) ? context : Context.NONE; } + /** + * Get the {@link RequestOptions} passed into the proxy method. + * + * @param swaggerMethodArguments the arguments passed to the proxy method + * @return the request options + */ + public RequestOptions setRequestOptions(Object[] swaggerMethodArguments) { + return CoreUtils.findFirstOfType(swaggerMethodArguments, RequestOptions.class); + } + /** * Get whether or not the provided response status code is one of the expected status codes for this Swagger * method. diff --git a/sdk/core/azure-core/src/main/java/module-info.java b/sdk/core/azure-core/src/main/java/module-info.java index e67c0592af2a0..fbdc6c2a747b7 100644 --- a/sdk/core/azure-core/src/main/java/module-info.java +++ b/sdk/core/azure-core/src/main/java/module-info.java @@ -38,6 +38,7 @@ opens com.azure.core.implementation to com.fasterxml.jackson.databind; opens com.azure.core.implementation.logging to com.fasterxml.jackson.databind; opens com.azure.core.implementation.serializer to com.fasterxml.jackson.databind; + opens com.azure.core.http.rest to com.fasterxml.jackson.databind; // Service Provider Interfaces uses com.azure.core.http.HttpClientProvider; diff --git a/sdk/core/azure-core/src/samples/java/com/azure/core/http/rest/RequestOptionsJavaDocCodeSnippets.java b/sdk/core/azure-core/src/samples/java/com/azure/core/http/rest/RequestOptionsJavaDocCodeSnippets.java new file mode 100644 index 0000000000000..46a49c2d672ad --- /dev/null +++ b/sdk/core/azure-core/src/samples/java/com/azure/core/http/rest/RequestOptionsJavaDocCodeSnippets.java @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.http.rest; + +import com.azure.core.http.HttpMethod; +import com.azure.core.util.BinaryData; + +import javax.json.Json; +import javax.json.JsonArray; +import javax.json.JsonObject; + +/** + * JavaDoc code snippets for {@link RequestOptions}. + */ +public class RequestOptionsJavaDocCodeSnippets { + + /** + * Sample to demonstrate how to create an instance of {@link RequestOptions}. + * @return An instance of {@link RequestOptions}. + */ + public RequestOptions createInstance() { + // BEGIN: com.azure.core.http.rest.requestoptions.instantiation + RequestOptions options = new RequestOptions() + .setBody(BinaryData.fromString("{\"name\":\"Fluffy\"}")) + .addHeader("x-ms-pet-version", "2021-06-01"); + // END: com.azure.core.http.rest.requestoptions.instantiation + return options; + } + + /** + * Sample to demonstrate setting the JSON request body in a {@link RequestOptions}. + * @return An instance of {@link RequestOptions}. + */ + public RequestOptions setJsonRequestBodyInRequestOptions() { + // BEGIN: com.azure.core.http.rest.requestoptions.createjsonrequest + JsonArray photoUrls = Json.createArrayBuilder() + .add("https://imgur.com/pet1") + .add("https://imgur.com/pet2") + .build(); + + JsonArray tags = Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("id", 0) + .add("name", "Labrador") + .build()) + .add(Json.createObjectBuilder() + .add("id", 1) + .add("name", "2021") + .build()) + .build(); + + JsonObject requestBody = Json.createObjectBuilder() + .add("id", 0) + .add("name", "foo") + .add("status", "available") + .add("category", Json.createObjectBuilder().add("id", 0).add("name", "dog")) + .add("photoUrls", photoUrls) + .add("tags", tags) + .build(); + + String requestBodyStr = requestBody.toString(); + // END: com.azure.core.http.rest.requestoptions.createjsonrequest + + // BEGIN: com.azure.core.http.rest.requestoptions.postrequest + RequestOptions options = new RequestOptions() + .addRequestCallback(request -> request + // may already be set if request is created from a client + .setUrl("https://petstore.example.com/pet") + .setHttpMethod(HttpMethod.POST) + .setBody(requestBodyStr) + .setHeader("Content-Type", "application/json")); + // END: com.azure.core.http.rest.requestoptions.postrequest + return options; + } +} diff --git a/sdk/core/azure-core/src/test/java/com/azure/core/http/rest/RequestOptionsTests.java b/sdk/core/azure-core/src/test/java/com/azure/core/http/rest/RequestOptionsTests.java new file mode 100644 index 0000000000000..ec0ec5694e13c --- /dev/null +++ b/sdk/core/azure-core/src/test/java/com/azure/core/http/rest/RequestOptionsTests.java @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package com.azure.core.http.rest; + +import com.azure.core.http.HttpHeaders; +import com.azure.core.http.HttpMethod; +import com.azure.core.http.HttpRequest; +import com.azure.core.util.BinaryData; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.net.MalformedURLException; +import java.net.URL; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class RequestOptionsTests { + @Test + public void addQueryParam() throws MalformedURLException { + final HttpRequest request = new HttpRequest(HttpMethod.POST, new URL("http://request.url")); + + RequestOptions options = new RequestOptions() + .addQueryParam("foo", "bar") + .addQueryParam("$skipToken", "1"); + options.getRequestCallback().accept(request); + + assertTrue(request.getUrl().toString().contains("?foo=bar&%24skipToken=1")); + } + + @Test + public void addHeader() throws MalformedURLException { + final HttpRequest request = new HttpRequest(HttpMethod.POST, new URL("http://request.url")); + + RequestOptions options = new RequestOptions() + .addHeader("x-ms-foo", "bar") + .addHeader("Content-Type", "application/json"); + options.getRequestCallback().accept(request); + + HttpHeaders headers = request.getHeaders(); + assertEquals("bar", headers.getValue("x-ms-foo")); + assertEquals("application/json", headers.getValue("Content-Type")); + } + + @Test + public void setBody() throws MalformedURLException { + final HttpRequest request = new HttpRequest(HttpMethod.POST, new URL("http://request.url")); + + String expected = "{\"id\":\"123\"}"; + + RequestOptions options = new RequestOptions() + .setBody(BinaryData.fromString(expected)); + options.getRequestCallback().accept(request); + + StepVerifier.create(BinaryData.fromFlux(request.getBody()).map(BinaryData::toString)) + .expectNext(expected) + .verifyComplete(); + } + + @Test + public void addRequestCallback() throws MalformedURLException { + final HttpRequest request = new HttpRequest(HttpMethod.POST, new URL("http://request.url")); + + RequestOptions options = new RequestOptions() + .addHeader("x-ms-foo", "bar") + .addRequestCallback(r -> r.setHttpMethod(HttpMethod.GET)) + .addRequestCallback(r -> r.setUrl("https://request.url")) + .addQueryParam("$skipToken", "1") + .addRequestCallback(r -> r.setHeader("x-ms-foo", "baz")); + + options.getRequestCallback().accept(request); + + HttpHeaders headers = request.getHeaders(); + assertEquals("baz", headers.getValue("x-ms-foo")); + assertEquals(HttpMethod.GET, request.getHttpMethod()); + assertEquals("https://request.url?%24skipToken=1", request.getUrl().toString()); + } +} diff --git a/sdk/core/azure-core/src/test/java/com/azure/core/http/rest/SwaggerMethodParserTests.java b/sdk/core/azure-core/src/test/java/com/azure/core/http/rest/SwaggerMethodParserTests.java index 67f9367b275ce..964a3fd35725f 100644 --- a/sdk/core/azure-core/src/test/java/com/azure/core/http/rest/SwaggerMethodParserTests.java +++ b/sdk/core/azure-core/src/test/java/com/azure/core/http/rest/SwaggerMethodParserTests.java @@ -28,6 +28,7 @@ import com.azure.core.http.HttpMethod; import com.azure.core.implementation.UnixTime; import com.azure.core.util.Base64Url; +import com.azure.core.util.BinaryData; import com.azure.core.util.Context; import com.azure.core.util.DateTimeRfc1123; import com.azure.core.util.UrlBuilder; @@ -480,6 +481,12 @@ public void setContext(SwaggerMethodParser swaggerMethodParser, Object[] argumen assertEquals(expectedContext, swaggerMethodParser.setContext(arguments)); } + @ParameterizedTest + @MethodSource("setRequestOptionsSupplier") + public void setRequestOptions(SwaggerMethodParser swaggerMethodParser, Object[] arguments, RequestOptions expectedRequestOptions) { + assertEquals(expectedRequestOptions, swaggerMethodParser.setRequestOptions(arguments)); + } + private static Stream setContextSupplier() throws NoSuchMethodException { Method method = OperationMethods.class.getDeclaredMethod("getMethod"); SwaggerMethodParser swaggerMethodParser = new SwaggerMethodParser(method, "https://raw.host.com"); @@ -494,6 +501,34 @@ private static Stream setContextSupplier() throws NoSuchMethodExcepti ); } + private static Stream setRequestOptionsSupplier() throws NoSuchMethodException { + Method method = OperationMethods.class.getDeclaredMethod("getMethod"); + SwaggerMethodParser swaggerMethodParser = new SwaggerMethodParser(method, "https://raw.host.com"); + + RequestOptions bodyOptions = new RequestOptions() + .setBody(BinaryData.fromString("{\"id\":\"123\"}")); + + RequestOptions headerQueryOptions = new RequestOptions() + .addHeader("x-ms-foo", "bar") + .addQueryParam("foo", "bar"); + + RequestOptions urlOptions = new RequestOptions() + .addRequestCallback(httpRequest -> httpRequest.setUrl("https://foo.host.com")); + + RequestOptions statusOptionOptions = new RequestOptions() + .setThrowOnError(false); + + return Stream.of( + Arguments.of(swaggerMethodParser, null, null), + Arguments.of(swaggerMethodParser, toObjectArray(), null), + Arguments.of(swaggerMethodParser, toObjectArray("string"), null), + Arguments.of(swaggerMethodParser, toObjectArray(bodyOptions), bodyOptions), + Arguments.of(swaggerMethodParser, toObjectArray("string", headerQueryOptions), headerQueryOptions), + Arguments.of(swaggerMethodParser, toObjectArray("string1", "string2", urlOptions), urlOptions), + Arguments.of(swaggerMethodParser, toObjectArray(statusOptionOptions), statusOptionOptions) + ); + } + interface ExpectedStatusCodeMethods { @Get("test") void noExpectedStatusCodes();