From 12b18ee255d3fabe13bb3969df40753b29f830d5 Mon Sep 17 00:00:00 2001 From: Vadym Matsishevskyi <25311427+vam-google@users.noreply.github.com> Date: Mon, 12 Oct 2020 13:16:33 -0700 Subject: [PATCH] feat: REST Gapic (REGAPIC) Support (#1177) --- build.gradle | 2 + gax-httpjson/BUILD.bazel | 2 + gax-httpjson/build.gradle | 2 + .../ApiMessageHttpResponseParser.java | 12 +- .../api/gax/httpjson/FieldsExtractor.java | 41 +++++ .../api/gax/httpjson/HttpRequestRunnable.java | 19 ++- .../api/gax/httpjson/HttpResponseParser.java | 19 ++- .../ProtoMessageRequestFormatter.java | 123 ++++++++++++++ .../httpjson/ProtoMessageResponseParser.java | 79 +++++++++ .../api/gax/httpjson/ProtoRestSerializer.java | 156 ++++++++++++++++++ .../httpjson/RestSerializationException.java | 50 ++++++ .../gax/httpjson/HttpRequestRunnableTest.java | 19 +++ .../ProtoMessageRequestFormatterTest.java | 133 +++++++++++++++ .../ProtoMessageResponseParserTest.java | 99 +++++++++++ .../gax/httpjson/ProtoRestSerializerTest.java | 145 ++++++++++++++++ repositories.bzl | 8 + 16 files changed, 899 insertions(+), 10 deletions(-) create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/FieldsExtractor.java create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageRequestFormatter.java create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoRestSerializer.java create mode 100644 gax-httpjson/src/main/java/com/google/api/gax/httpjson/RestSerializationException.java create mode 100644 gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageRequestFormatterTest.java create mode 100644 gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageResponseParserTest.java create mode 100644 gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoRestSerializerTest.java diff --git a/build.gradle b/build.gradle index dac16d62b..340aa3c45 100644 --- a/build.gradle +++ b/build.gradle @@ -132,6 +132,8 @@ subprojects { 'maven.io_grpc_grpc_protobuf': "io.grpc:grpc-protobuf:${libraries['version.io_grpc']}", 'maven.io_grpc_grpc_netty_shaded': "io.grpc:grpc-netty-shaded:${libraries['version.io_grpc']}", 'maven.io_grpc_grpc_alts': "io.grpc:grpc-alts:${libraries['version.io_grpc']}", + 'maven.com_google_protobuf': "com.google.protobuf:protobuf-java:${libraries['version.com_google_protobuf']}", + 'maven.com_google_protobuf_java_util': "com.google.protobuf:protobuf-java-util:${libraries['version.com_google_protobuf']}" ]) } diff --git a/gax-httpjson/BUILD.bazel b/gax-httpjson/BUILD.bazel index 2f230993c..5d2e71e4b 100644 --- a/gax-httpjson/BUILD.bazel +++ b/gax-httpjson/BUILD.bazel @@ -20,6 +20,8 @@ _COMPILE_DEPS = [ "@com_google_auto_value_auto_value_annotations//jar", "@com_google_http_client_google_http_client_jackson2//jar", "@javax_annotation_javax_annotation_api//jar", + "@com_google_protobuf//:protobuf_java", + "@com_google_protobuf_java_util//jar", "//gax:gax", ] diff --git a/gax-httpjson/build.gradle b/gax-httpjson/build.gradle index 51641b8d5..c5ad017f9 100644 --- a/gax-httpjson/build.gradle +++ b/gax-httpjson/build.gradle @@ -5,6 +5,8 @@ project.version = "0.76.1" // {x-version-update:gax-httpjson:current} dependencies { compile project(':gax'), + libraries['maven.com_google_protobuf'], + libraries['maven.com_google_protobuf_java_util'], libraries['maven.com_google_code_gson_gson'], libraries['maven.com_google_guava_guava'], libraries['maven.com_google_code_findbugs_jsr305'], diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMessageHttpResponseParser.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMessageHttpResponseParser.java index 6931b07d9..e7c7848c1 100644 --- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMessageHttpResponseParser.java +++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ApiMessageHttpResponseParser.java @@ -32,12 +32,15 @@ import com.google.auto.value.AutoValue; import com.google.gson.Gson; import com.google.gson.GsonBuilder; +import com.google.gson.JsonIOException; +import com.google.gson.JsonSyntaxException; import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonWriter; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; /** Utility class to parse {@link ApiMessage}s from HTTP responses. */ @AutoValue @@ -91,8 +94,13 @@ public ResponseT parse(InputStream httpResponseBody) { return null; } else { Type responseType = getResponseInstance().getClass(); - return getResponseMarshaller() - .fromJson(new InputStreamReader(httpResponseBody), responseType); + try { + return getResponseMarshaller() + .fromJson( + new InputStreamReader(httpResponseBody, StandardCharsets.UTF_8), responseType); + } catch (JsonIOException | JsonSyntaxException e) { + throw new RestSerializationException(e); + } } } diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/FieldsExtractor.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/FieldsExtractor.java new file mode 100644 index 000000000..3e47076cb --- /dev/null +++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/FieldsExtractor.java @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import com.google.api.core.BetaApi; + +/** + * A functional interface to be implemented for each request message to extract specific fields from + * it. For advanced usage only. + */ +@BetaApi +public interface FieldsExtractor { + ParamsT extract(RequestT request); +} diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java index 1c449dca5..bbeb2362a 100644 --- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java +++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpRequestRunnable.java @@ -103,7 +103,8 @@ HttpRequest createHttpRequest() throws IOException { } // Populate URL path and query parameters. - GenericUrl url = new GenericUrl(getEndpoint() + requestFormatter.getPath(getRequest())); + String endpoint = normalizeEndpoint(getEndpoint()); + GenericUrl url = new GenericUrl(endpoint + requestFormatter.getPath(getRequest())); Map> queryParams = requestFormatter.getQueryParamNames(getRequest()); for (Entry> queryParam : queryParams.entrySet()) { if (queryParam.getValue() != null) { @@ -120,12 +121,26 @@ HttpRequest createHttpRequest() throws IOException { return httpRequest; } + // This will be frequently executed, so avoiding using regexps if not necessary. + private String normalizeEndpoint(String endpoint) { + String normalized = endpoint; + // Set protocol as https by default if not set explicitly + if (!normalized.contains("://")) { + normalized = "https://" + normalized; + } + + if (normalized.charAt(normalized.length() - 1) != '/') { + normalized += '/'; + } + + return normalized; + } + @Override public void run() { try { HttpRequest httpRequest = createHttpRequest(); HttpResponse httpResponse = httpRequest.execute(); - if (!httpResponse.isSuccessStatusCode()) { ApiExceptionFactory.createException( null, diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpResponseParser.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpResponseParser.java index c67bc4a44..0bb70568e 100644 --- a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpResponseParser.java +++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpResponseParser.java @@ -33,19 +33,26 @@ import com.google.api.core.InternalExtensionOnly; import java.io.InputStream; -/** Interface for classes that parse parts of Http responses into the parameterized message type. */ +/** Interface for classes that parse parts of HTTP responses into the parameterized message type. */ @InternalExtensionOnly public interface HttpResponseParser { - /* Parse the http body content JSON stream into the MessageFormatT. + /** + * Parse the http body content JSON stream into the MessageFormatT. * - * @param httpContent the body of an http response. */ + * @param httpContent the body of an HTTP response + * @throws RestSerializationException if failed to parse the {@code httpContent} to a valid {@code + * MessageFormatT} + */ MessageFormatT parse(InputStream httpContent); - /* Serialize an object into an HTTP body, which is written out to output. + /** + * Serialize an object into an HTTP body, which is written out to output. * - * @param response the object to serialize. - * @param output the output stream to append the serialization to. */ + * @param response the object to serialize + * @throws RestSerializationException if failed to serialize {@code response} to a valid {@code + * String} representation + */ @InternalApi String serialize(MessageFormatT response); } diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageRequestFormatter.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageRequestFormatter.java new file mode 100644 index 000000000..9f75ba576 --- /dev/null +++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageRequestFormatter.java @@ -0,0 +1,123 @@ +/* + * Copyright 2020 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import com.google.api.core.BetaApi; +import com.google.api.pathtemplate.PathTemplate; +import com.google.protobuf.Message; +import java.util.List; +import java.util.Map; + +/** Creates parts of a HTTP request from a protobuf message. */ +@BetaApi +public class ProtoMessageRequestFormatter + implements HttpRequestFormatter { + + private final FieldsExtractor requestBodyExtractor; + // Using of triple nested generics (which is not pretty) is predetermined by the + // Map> returned value type of the getQueryParamNames interface method + // implemented by this class. + private final FieldsExtractor>> queryParamsExtractor; + private final PathTemplate pathTemplate; + private final FieldsExtractor> pathVarsExtractor; + + private ProtoMessageRequestFormatter( + FieldsExtractor requestBodyExtractor, + FieldsExtractor>> queryParamsExtractor, + PathTemplate pathTemplate, + FieldsExtractor> pathVarsExtractor) { + this.requestBodyExtractor = requestBodyExtractor; + this.queryParamsExtractor = queryParamsExtractor; + this.pathTemplate = pathTemplate; + this.pathVarsExtractor = pathVarsExtractor; + } + + public static + ProtoMessageRequestFormatter.Builder newBuilder() { + return new Builder<>(); + } + + /* {@inheritDoc} */ + @Override + public Map> getQueryParamNames(RequestT apiMessage) { + return queryParamsExtractor.extract(apiMessage); + } + + /* {@inheritDoc} */ + @Override + public String getRequestBody(RequestT apiMessage) { + return requestBodyExtractor.extract(apiMessage); + } + + /* {@inheritDoc} */ + @Override + public String getPath(RequestT apiMessage) { + return pathTemplate.instantiate(pathVarsExtractor.extract(apiMessage)); + } + + /* {@inheritDoc} */ + @Override + public PathTemplate getPathTemplate() { + return pathTemplate; + } + + // This has class has compound setter methods (multiple arguments in setters), that is why not + // using @AutoValue. + public static class Builder { + private FieldsExtractor requestBodyExtractor; + private FieldsExtractor>> queryParamsExtractor; + private String path; + private FieldsExtractor> pathVarsExtractor; + + public Builder setRequestBodyExtractor( + FieldsExtractor requestBodyExtractor) { + this.requestBodyExtractor = requestBodyExtractor; + return this; + } + + public Builder setQueryParamsExtractor( + FieldsExtractor>> queryParamsExtractor) { + this.queryParamsExtractor = queryParamsExtractor; + return this; + } + + public Builder setPath( + String path, FieldsExtractor> pathVarsExtractor) { + this.path = path; + this.pathVarsExtractor = pathVarsExtractor; + return this; + } + + public ProtoMessageRequestFormatter build() { + return new ProtoMessageRequestFormatter<>( + requestBodyExtractor, queryParamsExtractor, PathTemplate.create(path), pathVarsExtractor); + } + } +} diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java new file mode 100644 index 000000000..084b725e1 --- /dev/null +++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoMessageResponseParser.java @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import com.google.api.core.BetaApi; +import com.google.protobuf.Message; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** The implementation of {@link HttpResponseParser} which works with protobuf messages. */ +@BetaApi +public class ProtoMessageResponseParser + implements HttpResponseParser { + + private final ResponseT defaultInstance; + + private ProtoMessageResponseParser(ResponseT defaultInstance) { + this.defaultInstance = defaultInstance; + } + + public static + ProtoMessageResponseParser.Builder newBuilder() { + return new ProtoMessageResponseParser.Builder<>(); + } + + /* {@inheritDoc} */ + @Override + public ResponseT parse(InputStream httpContent) { + return ProtoRestSerializer.create() + .fromJson(httpContent, StandardCharsets.UTF_8, defaultInstance.newBuilderForType()); + } + + /* {@inheritDoc} */ + @Override + public String serialize(ResponseT response) { + return ProtoRestSerializer.create().toJson(response); + } + + // Convert to @AutoValue if this class gets more complicated + public static class Builder { + private ResponseT defaultInstance; + + public Builder setDefaultInstance(ResponseT defaultInstance) { + this.defaultInstance = defaultInstance; + return this; + } + + public ProtoMessageResponseParser build() { + return new ProtoMessageResponseParser<>(defaultInstance); + } + } +} diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoRestSerializer.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoRestSerializer.java new file mode 100644 index 000000000..4ca49ed2d --- /dev/null +++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ProtoRestSerializer.java @@ -0,0 +1,156 @@ +/* + * Copyright 2020 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import com.google.api.core.BetaApi; +import com.google.common.collect.ImmutableList; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.util.JsonFormat; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Map; + +/** + * This class serializes/deserializes protobuf {@link Message} for REST interactions. It serializes + * requests protobuf messages into REST messages, splitting the message into the JSON request body, + * URL path parameters, and query parameters. It deserializes JSON responses into response protobuf + * message. + */ +@BetaApi +public class ProtoRestSerializer { + private ProtoRestSerializer() {} + + /** Creates a new instance of ProtoRestSerializer. */ + public static ProtoRestSerializer create() { + return new ProtoRestSerializer<>(); + } + + /** + * Serializes the data from {@code message} to a JSON string. The implementation relies on + * protobuf native JSON formatter. + * + * @param message a message to serialize + * @throws InvalidProtocolBufferException if failed to serialize the protobuf message to JSON + * format + */ + String toJson(RequestT message) { + try { + return JsonFormat.printer().print(message); + } catch (InvalidProtocolBufferException e) { + throw new RestSerializationException("Failed to serialize message to JSON", e); + } + } + + /** + * Deserializes a {@code message} from an input stream to a protobuf message. + * + * @param message the input stream with a JSON-encoded message in it + * @param messageCharset the message charset + * @param builder an empty builder for the specific {@code RequestT} message to serialize + * @throws RestSerializationException if failed to deserialize a protobuf message from the JSON + * stream + */ + @SuppressWarnings("unchecked") + RequestT fromJson(InputStream message, Charset messageCharset, Message.Builder builder) { + try (Reader json = new InputStreamReader(message, messageCharset)) { + JsonFormat.parser().ignoringUnknownFields().merge(json, builder); + return (RequestT) builder.build(); + } catch (IOException e) { + throw new RestSerializationException("Failed to parse response message", e); + } + } + + /** + * Puts a message field in {@code fields} map which will be used to populate URL path of a + * request. + * + * @param fields a map with serialized fields + * @param fieldName a field name + * @param fieldValue a field value + */ + public void putPathParam(Map fields, String fieldName, Object fieldValue) { + if (isDefaultValue(fieldName, fieldValue)) { + return; + } + fields.put(fieldName, String.valueOf(fieldValue)); + } + + /** + * Puts a message field in {@code fields} map which will be used to populate query parameters of a + * request. + * + * @param fields a map with serialized fields + * @param fieldName a field name + * @param fieldValue a field value + */ + public void putQueryParam(Map> fields, String fieldName, Object fieldValue) { + // Avoids empty query parameter + if (isDefaultValue(fieldName, fieldValue)) { + return; + } + + ImmutableList.Builder paramValueList = ImmutableList.builder(); + if (fieldValue instanceof List) { + for (Object fieldValueItem : (List) fieldValue) { + paramValueList.add(String.valueOf(fieldValueItem)); + } + } else { + paramValueList.add(String.valueOf(fieldValue)); + } + + fields.put(fieldName, paramValueList.build()); + } + + /** + * Serializes a message to a request body in a form of JSON-encoded string. + * + * @param fieldName a name of a request message field this message belongs to + * @param fieldValue a field value to serialize + */ + public String toBody(String fieldName, RequestT fieldValue) { + return toJson(fieldValue); + } + + private boolean isDefaultValue(String fieldName, Object fieldValue) { + // TODO: Revisit this approach to ensure proper default-value handling as per design. + if (fieldValue instanceof Number) { + return ((Number) fieldValue).longValue() == 0L; + } else if (fieldValue instanceof String) { + return ((String) fieldValue).isEmpty(); + } + + return false; + } +} diff --git a/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RestSerializationException.java b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RestSerializationException.java new file mode 100644 index 000000000..7f5252595 --- /dev/null +++ b/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RestSerializationException.java @@ -0,0 +1,50 @@ +/* + * Copyright 2020 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import com.google.api.core.BetaApi; + +/** + * An exception thrown when a protobuf message cannot be serialized/deserialized for REST + * interactions. + */ +@BetaApi +public class RestSerializationException extends RuntimeException { + + private static final long serialVersionUID = -6485633460933364916L; + + public RestSerializationException(Throwable cause) { + super(cause); + } + + public RestSerializationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java index e753f205d..752a01946 100644 --- a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java +++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpRequestRunnableTest.java @@ -168,6 +168,25 @@ public void testRequestUrl() throws IOException { Truth.assertThat(httpRequest.getUrl().toString()).isEqualTo(expectedUrl); } + @Test + public void testRequestUrlUnnormalized() throws IOException { + httpRequestRunnable = + HttpRequestRunnable.newBuilder() + .setHttpJsonCallOptions(fakeCallOptions) + .setEndpoint("www.googleapis.com/animals/v1/projects") + .setRequest(catMessage) + .setApiMethodDescriptor(methodDescriptor) + .setHttpTransport(new MockHttpTransport()) + .setJsonFactory(new JacksonFactory()) + .setResponseFuture(SettableApiFuture.create()) + .build(); + HttpRequest httpRequest = httpRequestRunnable.createHttpRequest(); + Truth.assertThat(httpRequest.getContent()).isInstanceOf(EmptyContent.class); + String expectedUrl = + "https://www.googleapis.com/animals/v1/projects/name/feline?food=bird&food=mouse&size=small"; + Truth.assertThat(httpRequest.getUrl().toString()).isEqualTo(expectedUrl); + } + // TODO(andrealin): test request body private static class CatMessage extends FakeApiMessage { diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageRequestFormatterTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageRequestFormatterTest.java new file mode 100644 index 000000000..e8986f61d --- /dev/null +++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageRequestFormatterTest.java @@ -0,0 +1,133 @@ +/* + * Copyright 2020 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.api.gax.httpjson; + +import com.google.common.truth.Truth; +import com.google.protobuf.Field; +import com.google.protobuf.Field.Cardinality; +import com.google.protobuf.Option; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class ProtoMessageRequestFormatterTest { + private Field field; + private HttpRequestFormatter formatter; + + @Before + public void setUp() { + field = + Field.newBuilder() + .setNumber(2) + .setName("field_name1") + .addOptions(Option.newBuilder().setName("opt_name1").build()) + .addOptions(Option.newBuilder().setName("opt_name2").build()) + .setCardinality(Cardinality.CARDINALITY_OPTIONAL) + .build(); + + formatter = + ProtoMessageRequestFormatter.newBuilder() + .setPath( + "/api/v1/names/{name}/aggregated", + new FieldsExtractor>() { + @Override + public Map extract(Field request) { + Map fields = new HashMap<>(); + ProtoRestSerializer serializer = ProtoRestSerializer.create(); + serializer.putPathParam(fields, "name", request.getName()); + serializer.putPathParam(fields, "kindValue", request.getKindValue()); + return fields; + } + }) + .setQueryParamsExtractor( + new FieldsExtractor>>() { + @Override + public Map> extract(Field request) { + Map> fields = new HashMap<>(); + ProtoRestSerializer serializer = ProtoRestSerializer.create(); + serializer.putQueryParam(fields, "number", request.getNumber()); + serializer.putQueryParam(fields, "typeUrl", request.getTypeUrl()); + return fields; + } + }) + .setRequestBodyExtractor( + new FieldsExtractor() { + @Override + public String extract(Field request) { + ProtoRestSerializer serializer = ProtoRestSerializer.create(); + return serializer.toBody("field", request); + } + }) + .build(); + } + + @Test + public void getQueryParamNames() { + Map> queryParamNames = formatter.getQueryParamNames(field); + Map> expected = new HashMap<>(); + expected.put("number", Arrays.asList("2")); + Truth.assertThat(queryParamNames).isEqualTo(expected); + } + + @Test + public void getRequestBody() { + String bodyJson = formatter.getRequestBody(field); + String expectedBodyJson = + "{\n" + + " \"cardinality\": \"CARDINALITY_OPTIONAL\",\n" + + " \"number\": 2,\n" + + " \"name\": \"field_name1\",\n" + + " \"options\": [{\n" + + " \"name\": \"opt_name1\"\n" + + " }, {\n" + + " \"name\": \"opt_name2\"\n" + + " }]\n" + + "}"; + Truth.assertThat(bodyJson).isEqualTo(expectedBodyJson); + } + + @Test + public void getPath() { + String path = formatter.getPath(field); + Truth.assertThat(path).isEqualTo("api/v1/names/field_name1/aggregated"); + } + + @Test + public void getPathTemplate() { + String path = + formatter.getPathTemplate().instantiate(Collections.singletonMap("name", "field_name1")); + Truth.assertThat(path).isEqualTo("api/v1/names/field_name1/aggregated"); + } +} diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageResponseParserTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageResponseParserTest.java new file mode 100644 index 000000000..3794f66b1 --- /dev/null +++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoMessageResponseParserTest.java @@ -0,0 +1,99 @@ +/* + * Copyright 2020 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.api.gax.httpjson; + +import com.google.common.truth.Truth; +import com.google.protobuf.Field; +import com.google.protobuf.Field.Cardinality; +import com.google.protobuf.Option; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class ProtoMessageResponseParserTest { + private ProtoMessageResponseParser parser; + private Field field; + private String fieldJson; + + @Before + public void setUp() { + parser = + ProtoMessageResponseParser.newBuilder() + .setDefaultInstance(Field.getDefaultInstance()) + .build(); + + field = + Field.newBuilder() + .setNumber(2) + .setName("field_name1") + .addOptions(Option.newBuilder().setName("opt_name1").build()) + .addOptions(Option.newBuilder().setName("opt_name2").build()) + .setCardinality(Cardinality.CARDINALITY_OPTIONAL) + .build(); + + fieldJson = + "{\n" + + " \"cardinality\": \"CARDINALITY_OPTIONAL\",\n" + + " \"number\": 2,\n" + + " \"name\": \"field_name1\",\n" + + " \"options\": [{\n" + + " \"name\": \"opt_name1\"\n" + + " }, {\n" + + " \"name\": \"opt_name2\"\n" + + " }]\n" + + "}"; + } + + @Test + public void parse() { + Field actualField = + parser.parse(new ByteArrayInputStream(fieldJson.getBytes(StandardCharsets.UTF_8))); + Truth.assertThat(actualField).isEqualTo(field); + } + + @Test + public void parseInvalidJson() { + try { + parser.parse(new ByteArrayInputStream("invalid".getBytes(StandardCharsets.UTF_8))); + Assert.fail(); + } catch (RestSerializationException e) { + Truth.assertThat(e.getCause()).isInstanceOf(IOException.class); + } + } + + @Test + public void serialize() { + String actualFieldJson = parser.serialize(field); + Truth.assertThat(actualFieldJson).isEqualTo(fieldJson); + } +} diff --git a/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoRestSerializerTest.java b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoRestSerializerTest.java new file mode 100644 index 000000000..77f9f72d3 --- /dev/null +++ b/gax-httpjson/src/test/java/com/google/api/gax/httpjson/ProtoRestSerializerTest.java @@ -0,0 +1,145 @@ +/* + * Copyright 2020 Google LLC + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Google LLC nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.api.gax.httpjson; + +import com.google.common.truth.Truth; +import com.google.protobuf.Field; +import com.google.protobuf.Field.Cardinality; +import com.google.protobuf.Option; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class ProtoRestSerializerTest { + private ProtoRestSerializer requestSerializer; + private Field field; + private String fieldJson; + + @Before + public void setUp() { + requestSerializer = ProtoRestSerializer.create(); + field = + Field.newBuilder() + .setNumber(2) + .setName("field_name1") + .addOptions(Option.newBuilder().setName("opt_name1").build()) + .addOptions(Option.newBuilder().setName("opt_name2").build()) + .setCardinality(Cardinality.CARDINALITY_OPTIONAL) + .build(); + + fieldJson = + "{\n" + + " \"cardinality\": \"CARDINALITY_OPTIONAL\",\n" + + " \"number\": 2,\n" + + " \"name\": \"field_name1\",\n" + + " \"options\": [{\n" + + " \"name\": \"opt_name1\"\n" + + " }, {\n" + + " \"name\": \"opt_name2\"\n" + + " }]\n" + + "}"; + } + + @Test + public void toJson() { + String fieldToJson = requestSerializer.toJson(field); + Truth.assertThat(fieldToJson).isEqualTo(fieldJson); + } + + @Test + public void fromJson() { + Field fieldFromJson = + requestSerializer.fromJson( + new ByteArrayInputStream(fieldJson.getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8, + Field.newBuilder()); + + Truth.assertThat(fieldFromJson).isEqualTo(field); + } + + @Test + public void fromJsonInvalidJson() { + try { + requestSerializer.fromJson( + new ByteArrayInputStream("heh".getBytes(StandardCharsets.UTF_8)), + StandardCharsets.UTF_8, + Field.newBuilder()); + Assert.fail(); + } catch (RestSerializationException e) { + Truth.assertThat(e.getCause()).isInstanceOf(IOException.class); + } + } + + @Test + public void putPathParam() { + Map fields = new HashMap<>(); + requestSerializer.putPathParam(fields, "optName1", 1); + requestSerializer.putPathParam(fields, "optName2", 0); + requestSerializer.putPathParam(fields, "optName3", "three"); + requestSerializer.putPathParam(fields, "optName4", ""); + + Map expectedFields = new HashMap<>(); + expectedFields.put("optName1", "1"); + expectedFields.put("optName3", "three"); + + Truth.assertThat(fields).isEqualTo(expectedFields); + } + + @Test + public void putQueryParam() { + Map> fields = new HashMap<>(); + requestSerializer.putQueryParam(fields, "optName1", 1); + requestSerializer.putQueryParam(fields, "optName2", 0); + requestSerializer.putQueryParam(fields, "optName3", "three"); + requestSerializer.putQueryParam(fields, "optName4", ""); + requestSerializer.putQueryParam(fields, "optName5", Arrays.asList("four", "five")); + + Map> expectedFields = new HashMap<>(); + expectedFields.put("optName1", Arrays.asList("1")); + expectedFields.put("optName3", Arrays.asList("three")); + expectedFields.put("optName5", Arrays.asList("four", "five")); + + Truth.assertThat(fields).isEqualTo(expectedFields); + } + + @Test + public void toBody() { + String body = requestSerializer.toBody("bodyField1", field); + Truth.assertThat(body).isEqualTo(fieldJson); + } +} diff --git a/repositories.bzl b/repositories.bzl index 0f909e131..2896b375c 100644 --- a/repositories.bzl +++ b/repositories.bzl @@ -72,6 +72,14 @@ def com_google_api_gax_java_repositories(): urls = ["https://github.com/bazelbuild/bazel-skylib/archive/%s.zip" % _bazel_skylib_version], ) + _maybe( + jvm_maven_import_external, + name = "com_google_protobuf_java_util", + artifact = "com.google.protobuf:protobuf-java-util:%s" % PROPERTIES["version.com_google_protobuf"], + server_urls = ["https://repo.maven.apache.org/maven2/", "http://repo1.maven.org/maven2/"], + licenses = ["notice", "reciprocal"], + ) + _maybe( jvm_maven_import_external, name = "io_grpc_grpc_netty_shaded",