diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/DefaultHttpJsonTranscodingOptions.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/DefaultHttpJsonTranscodingOptions.java new file mode 100644 index 00000000000..3fd31411c3f --- /dev/null +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/DefaultHttpJsonTranscodingOptions.java @@ -0,0 +1,72 @@ +/* + * Copyright 2022 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.grpc; + +import java.util.Objects; +import java.util.Set; + +import com.google.common.base.MoreObjects; + +final class DefaultHttpJsonTranscodingOptions implements HttpJsonTranscodingOptions { + + static final HttpJsonTranscodingOptions DEFAULT = HttpJsonTranscodingOptions.builder().build(); + + private final Set queryParamMatchRules; + private final UnframedGrpcErrorHandler errorHandler; + + DefaultHttpJsonTranscodingOptions(Set queryParamMatchRules, + UnframedGrpcErrorHandler errorHandler) { + this.queryParamMatchRules = queryParamMatchRules; + this.errorHandler = errorHandler; + } + + @Override + public Set queryParamMatchRules() { + return queryParamMatchRules; + } + + @Override + public UnframedGrpcErrorHandler errorHandler() { + return errorHandler; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof HttpJsonTranscodingOptions)) { + return false; + } + final HttpJsonTranscodingOptions that = (HttpJsonTranscodingOptions) o; + return queryParamMatchRules.equals(that.queryParamMatchRules()) && + errorHandler.equals(that.errorHandler()); + } + + @Override + public int hashCode() { + return Objects.hash(queryParamMatchRules, errorHandler); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("queryParamMatchRules", queryParamMatchRules) + .add("errorHandler", errorHandler) + .toString(); + } +} diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java index 2f479a8c151..38b66d97664 100644 --- a/grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/GrpcServiceBuilder.java @@ -130,6 +130,8 @@ public final class GrpcServiceBuilder { @Nullable private UnframedGrpcErrorHandler httpJsonTranscodingErrorHandler; + private HttpJsonTranscodingOptions httpJsonTranscodingOptions = HttpJsonTranscodingOptions.of(); + private Set supportedSerializationFormats = DEFAULT_SUPPORTED_SERIALIZATION_FORMATS; private int maxRequestMessageLength = AbstractMessageDeframer.NO_MAX_INBOUND_MESSAGE_SIZE; @@ -657,11 +659,56 @@ public GrpcServiceBuilder enableHttpJsonTranscoding(boolean enableHttpJsonTransc return this; } + /** + * Enables HTTP/JSON transcoding using the gRPC wire protocol. + * Provide {@link HttpJsonTranscodingOptions} to customize HttpJsonTranscoding. + * + *

Example: + *

{@code
+     * HttpJsonTranscodingOptions options =
+     *   HttpJsonTranscodingOptions.builder()
+     *                             .queryParamMatchRules(ORIGINAL_FIELD)
+     *                             ...
+     *                             .build();
+     *
+     * GrpcService.builder()
+     *            // Enable HttpJsonTranscoding and use the specified HttpJsonTranscodingOption
+     *            .enableHttpJsonTranscoding(options)
+     *            .build();
+     * }
+ * + *

Limitations: + *

    + *
  • Only unary methods (single request, single response) are supported.
  • + *
  • + * Message compression is not supported. + * {@link EncodingService} should be used instead for + * transport level encoding. + *
  • + *
  • + * Transcoding will not work if the {@link GrpcService} is configured with + * {@link ServerBuilder#serviceUnder(String, HttpService)}. + *
  • + *
+ * + * @see Transcoding HTTP/JSON to gRPC + */ + @UnstableApi + public GrpcServiceBuilder enableHttpJsonTranscoding(HttpJsonTranscodingOptions httpJsonTranscodingOptions) { + requireNonNull(httpJsonTranscodingOptions, "httpJsonTranscodingOptions"); + enableHttpJsonTranscoding = true; + this.httpJsonTranscodingOptions = httpJsonTranscodingOptions; + return this; + } + /** * Sets an error handler which handles an exception raised while serving a gRPC request transcoded from * an HTTP/JSON request. By default, {@link UnframedGrpcErrorHandler#ofJson()} would be set. + * + * @deprecated Use {@link HttpJsonTranscodingOptionsBuilder#errorHandler(UnframedGrpcErrorHandler)} instead. */ @UnstableApi + @Deprecated public GrpcServiceBuilder httpJsonTranscodingErrorHandler( UnframedGrpcErrorHandler httpJsonTranscodingErrorHandler) { requireNonNull(httpJsonTranscodingErrorHandler, "httpJsonTranscodingErrorHandler"); @@ -978,10 +1025,19 @@ public GrpcService build() { : UnframedGrpcErrorHandler.of()); } if (enableHttpJsonTranscoding) { - grpcService = HttpJsonTranscodingService.of( - grpcService, - httpJsonTranscodingErrorHandler != null ? httpJsonTranscodingErrorHandler - : UnframedGrpcErrorHandler.ofJson()); + final HttpJsonTranscodingOptions httpJsonTranscodingOptions; + if (httpJsonTranscodingErrorHandler != null) { + httpJsonTranscodingOptions = + HttpJsonTranscodingOptions + .builder() + .queryParamMatchRules( + this.httpJsonTranscodingOptions.queryParamMatchRules()) + .errorHandler(httpJsonTranscodingErrorHandler) + .build(); + } else { + httpJsonTranscodingOptions = this.httpJsonTranscodingOptions; + } + grpcService = HttpJsonTranscodingService.of(grpcService, httpJsonTranscodingOptions); } if (handlerRegistry.containsDecorators()) { grpcService = new GrpcDecoratingService(grpcService, handlerRegistry); diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingOptions.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingOptions.java new file mode 100644 index 00000000000..9c79299d3fe --- /dev/null +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingOptions.java @@ -0,0 +1,56 @@ +/* + * Copyright 2022 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.grpc; + +import java.util.Set; + +import com.google.protobuf.Message; + +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * User provided options for customizing {@link HttpJsonTranscodingService}. + */ +@UnstableApi +public interface HttpJsonTranscodingOptions { + + /** + * Returns a new {@link HttpJsonTranscodingOptionsBuilder}. + */ + static HttpJsonTranscodingOptionsBuilder builder() { + return new HttpJsonTranscodingOptionsBuilder(); + } + + /** + * Returns the default {@link HttpJsonTranscodingOptions}. + */ + static HttpJsonTranscodingOptions of() { + return DefaultHttpJsonTranscodingOptions.DEFAULT; + } + + /** + * Returns the {@link HttpJsonTranscodingQueryParamMatchRule}s which is used to match fields in a + * {@link Message} with query parameters. + */ + Set queryParamMatchRules(); + + /** + * Return the {@link UnframedGrpcErrorHandler} which handles an exception raised while serving a gRPC + * request transcoded from an HTTP/JSON request. + */ + UnframedGrpcErrorHandler errorHandler(); +} diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingOptionsBuilder.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingOptionsBuilder.java new file mode 100644 index 00000000000..f5e279ffbbc --- /dev/null +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingOptionsBuilder.java @@ -0,0 +1,102 @@ +/* + * Copyright 2022 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.grpc; + +import static com.google.common.base.Preconditions.checkArgument; +import static java.util.Objects.requireNonNull; + +import java.util.EnumSet; +import java.util.Set; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import com.google.common.collect.Sets; +import com.google.protobuf.Message; + +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A builder for {@link HttpJsonTranscodingOptions}. + */ +@UnstableApi +public final class HttpJsonTranscodingOptionsBuilder { + + private static final EnumSet DEFAULT_QUERY_PARAM_MATCH_RULES = + EnumSet.of(HttpJsonTranscodingQueryParamMatchRule.ORIGINAL_FIELD); + + private UnframedGrpcErrorHandler errorHandler = UnframedGrpcErrorHandler.ofJson(); + + @Nullable + private Set queryParamMatchRules; + + HttpJsonTranscodingOptionsBuilder() {} + + /** + * Adds the specified {@link HttpJsonTranscodingQueryParamMatchRule} which is used + * to match {@link QueryParams} of an {@link HttpRequest} with fields in a {@link Message}. + * If not set, {@link HttpJsonTranscodingQueryParamMatchRule#ORIGINAL_FIELD} is used by default. + */ + public HttpJsonTranscodingOptionsBuilder queryParamMatchRules( + HttpJsonTranscodingQueryParamMatchRule... queryParamMatchRules) { + requireNonNull(queryParamMatchRules, "queryParamMatchRules"); + queryParamMatchRules(ImmutableList.copyOf(queryParamMatchRules)); + return this; + } + + /** + * Adds the specified {@link HttpJsonTranscodingQueryParamMatchRule} which is used + * to match {@link QueryParams} of an {@link HttpRequest} with fields in a {@link Message}. + * If not set, {@link HttpJsonTranscodingQueryParamMatchRule#ORIGINAL_FIELD} is used by default. + */ + public HttpJsonTranscodingOptionsBuilder queryParamMatchRules( + Iterable queryParamMatchRules) { + requireNonNull(queryParamMatchRules, "queryParamMatchRules"); + checkArgument(!Iterables.isEmpty(queryParamMatchRules), "Can't set an empty queryParamMatchRules"); + if (this.queryParamMatchRules == null) { + this.queryParamMatchRules = EnumSet.noneOf(HttpJsonTranscodingQueryParamMatchRule.class); + } + this.queryParamMatchRules.addAll(ImmutableList.copyOf(queryParamMatchRules)); + return this; + } + + /** + * Sets an error handler which handles an exception raised while serving a gRPC request transcoded from + * an HTTP/JSON request. By default, {@link UnframedGrpcErrorHandler#ofJson()} would be set. + */ + @UnstableApi + public HttpJsonTranscodingOptionsBuilder errorHandler(UnframedGrpcErrorHandler errorHandler) { + requireNonNull(errorHandler, "errorHandler"); + this.errorHandler = errorHandler; + return this; + } + + /** + * Returns a newly created {@link HttpJsonTranscodingOptions}. + */ + public HttpJsonTranscodingOptions build() { + final Set matchRules; + if (queryParamMatchRules == null) { + matchRules = DEFAULT_QUERY_PARAM_MATCH_RULES; + } else { + matchRules = Sets.immutableEnumSet(queryParamMatchRules); + } + return new DefaultHttpJsonTranscodingOptions(matchRules, errorHandler); + } +} diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingQueryParamMatchRule.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingQueryParamMatchRule.java new file mode 100644 index 00000000000..a92cf323994 --- /dev/null +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingQueryParamMatchRule.java @@ -0,0 +1,46 @@ +/* + * Copyright 2022 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.grpc; + +import com.google.protobuf.Message; + +import com.linecorp.armeria.common.HttpRequest; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * A naming rule to map {@link QueryParams} of an {@link HttpRequest} to fields in a {@link Message} for + * HTTP-JSON transcoding endpoint. + */ +@UnstableApi +public enum HttpJsonTranscodingQueryParamMatchRule { + /** + * Converts field names that are + * underscore_separated + * into lowerCamelCase before matching with {@link QueryParams} of an {@link HttpRequest}. + * + *

Note that field names which aren't {@code underscore_separated} may fail to + * convert correctly to lowerCamelCase. Therefore, don't use this option if you aren't following + * Protocol Buffer's + * naming conventions. + */ + LOWER_CAMEL_CASE, + /** + * Uses the original fields in .proto files to match {@link QueryParams} of an {@link HttpRequest}. + */ + ORIGINAL_FIELD +} diff --git a/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java index a87d2313868..eee053ad97e 100644 --- a/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java +++ b/grpc/src/main/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingService.java @@ -21,6 +21,7 @@ import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableMap.toImmutableMap; import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static com.linecorp.armeria.server.grpc.HttpJsonTranscodingQueryParamMatchRule.LOWER_CAMEL_CASE; import static java.util.Objects.requireNonNull; import java.io.IOException; @@ -127,9 +128,9 @@ final class HttpJsonTranscodingService extends AbstractUnframedGrpcService * to support HTTP/JSON to gRPC transcoding, a new {@link HttpJsonTranscodingService} instance * would be returned. Otherwise, the {@code delegate} would be returned. */ - static GrpcService of(GrpcService delegate, UnframedGrpcErrorHandler unframedGrpcErrorHandler) { + static GrpcService of(GrpcService delegate, HttpJsonTranscodingOptions httpJsonTranscodingOptions) { requireNonNull(delegate, "delegate"); - requireNonNull(unframedGrpcErrorHandler, "unframedGrpcErrorHandler"); + requireNonNull(httpJsonTranscodingOptions, "httpJsonTranscodingOptions"); final Map specs = new HashMap<>(); @@ -166,8 +167,17 @@ static GrpcService of(GrpcService delegate, UnframedGrpcErrorHandler unframedGrp final Route route = routeAndVariables.getKey(); final List pathVariables = routeAndVariables.getValue(); - final Map fields = - buildFields(methodDesc.getInputType(), ImmutableList.of(), ImmutableSet.of()); + final Map originalFields = + buildFields(methodDesc.getInputType(), ImmutableList.of(), ImmutableSet.of(), + false); + final Map camelCaseFields; + if (httpJsonTranscodingOptions.queryParamMatchRules().contains(LOWER_CAMEL_CASE)) { + camelCaseFields = + buildFields(methodDesc.getInputType(), ImmutableList.of(), ImmutableSet.of(), + true); + } else { + camelCaseFields = ImmutableMap.of(); + } if (specs.containsKey(route)) { logger.warn("{} is not added because the route is duplicate: {}", httpRule, route); @@ -177,7 +187,8 @@ static GrpcService of(GrpcService delegate, UnframedGrpcErrorHandler unframedGrp final String responseBody = getResponseBody(topLevelFields, httpRule.getResponseBody()); int order = 0; specs.put(route, new TranscodingSpec(order++, httpRule, methodDefinition, - serviceDesc, methodDesc, fields, pathVariables, + serviceDesc, methodDesc, originalFields, camelCaseFields, + pathVariables, responseBody)); for (HttpRule additionalHttpRule : httpRule.getAdditionalBindingsList()) { @Nullable @@ -186,7 +197,8 @@ static GrpcService of(GrpcService delegate, UnframedGrpcErrorHandler unframedGrp if (additionalRouteAndVariables != null) { specs.put(additionalRouteAndVariables.getKey(), new TranscodingSpec(order++, additionalHttpRule, methodDefinition, - serviceDesc, methodDesc, fields, + serviceDesc, methodDesc, originalFields, + camelCaseFields, additionalRouteAndVariables.getValue(), responseBody)); } @@ -198,7 +210,7 @@ static GrpcService of(GrpcService delegate, UnframedGrpcErrorHandler unframedGrp // We don't need to create a new HttpJsonTranscodingService instance in this case. return delegate; } - return new HttpJsonTranscodingService(delegate, ImmutableMap.copyOf(specs), unframedGrpcErrorHandler); + return new HttpJsonTranscodingService(delegate, ImmutableMap.copyOf(specs), httpJsonTranscodingOptions); } @Nullable @@ -287,7 +299,8 @@ static Entry> toRouteAndPathVariables(HttpRule httpRul private static Map buildFields(Descriptor desc, List parentNames, - Set visitedTypes) { + Set visitedTypes, + boolean useCamelCaseKeys) { final StringJoiner namePrefixJoiner = new StringJoiner("."); parentNames.forEach(namePrefixJoiner::add); final String namePrefix = namePrefixJoiner.length() == 0 ? "" : namePrefixJoiner.toString() + '.'; @@ -295,6 +308,13 @@ private static Map buildFields(Descriptor desc, final ImmutableMap.Builder builder = ImmutableMap.builder(); desc.getFields().forEach(field -> { final JavaType type = field.getJavaType(); + final String fieldName; + if (useCamelCaseKeys) { + fieldName = CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, field.getName()); + } else { + fieldName = field.getName(); + } + final String key = namePrefix + fieldName; switch (type) { case INT: case LONG: @@ -305,15 +325,13 @@ private static Map buildFields(Descriptor desc, case BYTE_STRING: case ENUM: // Use field name which is specified in proto file. - builder.put(namePrefix + field.getName(), - new Field(field, parentNames, field.getJavaType())); + builder.put(key, new Field(field, parentNames, field.getJavaType())); break; case MESSAGE: @Nullable final JavaType wellKnownFieldType = getJavaTypeForWellKnownTypes(field); if (wellKnownFieldType != null) { - builder.put(namePrefix + field.getName(), - new Field(field, parentNames, wellKnownFieldType)); + builder.put(key, new Field(field, parentNames, wellKnownFieldType)); break; } @@ -347,20 +365,20 @@ private static Map buildFields(Descriptor desc, builder.putAll(buildFields(typeDesc, ImmutableList.builder() .addAll(parentNames) - .add(field.getName()) + .add(fieldName) .build(), ImmutableSet.builder() .addAll(visitedTypes) .add(field.getMessageType()) - .build())); + .build(), + useCamelCaseKeys)); } catch (RecursiveTypeException e) { if (e.recursiveTypeDescriptor() != field.getMessageType()) { // Re-throw the exception if it is not caused by my field. throw e; } - builder.put(namePrefix + field.getName(), - new Field(field, parentNames, JavaType.MESSAGE)); + builder.put(key, new Field(field, parentNames, JavaType.MESSAGE)); } break; } @@ -486,16 +504,24 @@ private static Function generateResponseBodyConverter(Transc private final Map routeAndSpecs; private final Set routes; + private final boolean useCamelCaseQueryParams; + private final boolean useProtoFieldNameQueryParams; private HttpJsonTranscodingService(GrpcService delegate, Map routeAndSpecs, - UnframedGrpcErrorHandler unframedGrpcErrorHandler) { - super(delegate, unframedGrpcErrorHandler); + HttpJsonTranscodingOptions httpJsonTranscodingOptions) { + super(delegate, httpJsonTranscodingOptions.errorHandler()); this.routeAndSpecs = routeAndSpecs; routes = ImmutableSet.builder() .addAll(delegate.routes()) .addAll(routeAndSpecs.keySet()) .build(); + useCamelCaseQueryParams = + httpJsonTranscodingOptions.queryParamMatchRules() + .contains(LOWER_CAMEL_CASE); + useProtoFieldNameQueryParams = + httpJsonTranscodingOptions.queryParamMatchRules() + .contains(HttpJsonTranscodingQueryParamMatchRule.ORIGINAL_FIELD); } @Override @@ -508,7 +534,7 @@ public HttpEndpointSpecification httpEndpointSpecification(Route route) { final Set paramNames = spec.pathVariables.stream().map(PathVariable::name) .collect(toImmutableSet()); final Map parameterTypes = - spec.fields.entrySet().stream().collect( + spec.originalFields.entrySet().stream().collect( toImmutableMap(Entry::getKey, fieldEntry -> new Parameter(fieldEntry.getValue().type(), fieldEntry.getValue().isRepeated()))); @@ -594,9 +620,9 @@ private HttpResponse serve0(ServiceRequestContext ctx, HttpRequest req, /** * Converts the HTTP request to gRPC JSON with the {@link TranscodingSpec}. */ - private static HttpData convertToJson(ServiceRequestContext ctx, - AggregatedHttpRequest request, - TranscodingSpec spec) throws IOException { + private HttpData convertToJson(ServiceRequestContext ctx, + AggregatedHttpRequest request, + TranscodingSpec spec) throws IOException { try { switch (request.method()) { case GET: @@ -675,24 +701,38 @@ static Map populatePathVariables(ServiceRequestContext ctx, }).collect(toImmutableMap(Entry::getKey, Entry::getValue)); } - private static HttpData setParametersAndWriteJson(ObjectNode root, - ServiceRequestContext ctx, - TranscodingSpec spec) throws JsonProcessingException { + private HttpData setParametersAndWriteJson(ObjectNode root, + ServiceRequestContext ctx, + TranscodingSpec spec) throws JsonProcessingException { // Generate path variable name/value map. final Map resolvedPathVars = populatePathVariables(ctx, spec.pathVariables); - setParametersToNode(root, resolvedPathVars.entrySet(), spec); - if (ctx.query() != null) { - setParametersToNode(root, QueryParams.fromQueryString(ctx.query()), spec); + setParametersToNode(root, resolvedPathVars.entrySet(), spec, true); + final QueryParams params = ctx.queryParams(); + if (!params.isEmpty()) { + setParametersToNode(root, params, spec, false); } return HttpData.wrap(mapper.writeValueAsBytes(root)); } - private static void setParametersToNode(ObjectNode root, - Iterable> parameters, - TranscodingSpec spec) { + private void setParametersToNode(ObjectNode root, + Iterable> parameters, + TranscodingSpec spec, boolean pathVariables) { for (Map.Entry entry : parameters) { - final Field field = spec.fields.get(entry.getKey()); + Field field = null; + if (pathVariables) { + // The original field name should be used for the path variable + field = spec.originalFields.get(entry.getKey()); + } else { + // A query parameter can be matched with either an original field name or a camel case name + // depending on the `HttpJsonTranscodingOptions`. + if (useProtoFieldNameQueryParams) { + field = spec.originalFields.get(entry.getKey()); + } + if (field == null && useCamelCaseQueryParams) { + field = spec.camelCaseFields.get(entry.getKey()); + } + } if (field == null) { // Ignore unknown parameters. continue; @@ -797,7 +837,8 @@ static final class TranscodingSpec { private final ServerMethodDefinition method; private final Descriptors.ServiceDescriptor serviceDescriptor; private final Descriptors.MethodDescriptor methodDescriptor; - private final Map fields; + private final Map originalFields; + private final Map camelCaseFields; private final List pathVariables; @Nullable private final String responseBody; @@ -807,7 +848,8 @@ private TranscodingSpec(int order, ServerMethodDefinition method, ServiceDescriptor serviceDescriptor, MethodDescriptor methodDescriptor, - Map fields, + Map originalFields, + Map camelCaseFields, List pathVariables, @Nullable String responseBody) { this.order = order; @@ -815,7 +857,8 @@ private TranscodingSpec(int order, this.method = method; this.serviceDescriptor = serviceDescriptor; this.methodDescriptor = methodDescriptor; - this.fields = fields; + this.originalFields = originalFields; + this.camelCaseFields = camelCaseFields; this.pathVariables = pathVariables; this.responseBody = responseBody; } diff --git a/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java b/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java index a5f2a01daad..64e8cef9263 100644 --- a/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java +++ b/grpc/src/test/java/com/linecorp/armeria/it/grpc/HttpJsonTranscodingTest.java @@ -45,8 +45,10 @@ import com.fasterxml.jackson.core.TreeNode; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Streams; +import com.linecorp.armeria.client.BlockingWebClient; import com.linecorp.armeria.client.WebClient; import com.linecorp.armeria.client.grpc.GrpcClients; import com.linecorp.armeria.common.AggregatedHttpResponse; @@ -80,6 +82,7 @@ import com.linecorp.armeria.grpc.testing.Transcoding.GetMessageRequestV2; import com.linecorp.armeria.grpc.testing.Transcoding.GetMessageRequestV2.SubMessage; import com.linecorp.armeria.grpc.testing.Transcoding.GetMessageRequestV3; +import com.linecorp.armeria.grpc.testing.Transcoding.GetMessageRequestV4; import com.linecorp.armeria.grpc.testing.Transcoding.Message; import com.linecorp.armeria.grpc.testing.Transcoding.MessageType; import com.linecorp.armeria.grpc.testing.Transcoding.Recursive; @@ -91,6 +94,8 @@ import com.linecorp.armeria.server.docs.DocService; import com.linecorp.armeria.server.grpc.GrpcService; import com.linecorp.armeria.server.grpc.GrpcServiceBuilder; +import com.linecorp.armeria.server.grpc.HttpJsonTranscodingOptions; +import com.linecorp.armeria.server.grpc.HttpJsonTranscodingQueryParamMatchRule; import com.linecorp.armeria.testing.junit5.server.ServerExtension; import io.grpc.stub.StreamObserver; @@ -127,6 +132,16 @@ public void getMessageV3(GetMessageRequestV3 request, StreamObserver re responseObserver.onCompleted(); } + @Override + public void getMessageV4(GetMessageRequestV4 request, StreamObserver responseObserver) { + final String text = request.getMessageId() + ':' + + request.getQueryParameter() + ':' + + request.getParentField().getChildField() + ':' + + request.getParentField().getChildField2(); + responseObserver.onNext(Message.newBuilder().setText(text).build()); + responseObserver.onCompleted(); + } + @Override public void updateMessageV1(UpdateMessageRequestV1 request, StreamObserver responseObserver) { final String text = request.getMessageId() + ':' + @@ -271,26 +286,51 @@ public void echoResponseBodyNoMatching(EchoResponseBodyRequest request, } @RegisterExtension - static final ServerExtension server = createServer(false); + static final ServerExtension server = createServer(false, false, true); + + @RegisterExtension + static final ServerExtension serverPreservingProtoFieldNames = createServer(true, false, true); + + @RegisterExtension + static final ServerExtension serverCamelCaseQueryOnlyParameters = createServer(false, true, false); @RegisterExtension - static final ServerExtension serverPreservingProtoFieldNames = createServer(true); + static final ServerExtension serverCamelCaseQueryAndOriginalParameters = createServer(false, true, true); private final ObjectMapper mapper = JacksonUtil.newDefaultObjectMapper(); private final WebClient webClient = WebClient.builder(server.httpUri()).build(); - final WebClient webClientPreservingProtoFieldNames = + private final WebClient webClientPreservingProtoFieldNames = WebClient.builder(serverPreservingProtoFieldNames.httpUri()).build(); - static ServerExtension createServer(boolean preservingProtoFieldNames) { + private final BlockingWebClient webClientCamelCaseQueryOnlyParameters = + serverCamelCaseQueryOnlyParameters.blockingWebClient(); + + private final BlockingWebClient webClientCamelCaseQueryAndOriginalParameters = + serverCamelCaseQueryAndOriginalParameters.blockingWebClient(); + + static ServerExtension createServer(boolean preservingProtoFieldNames, boolean camelCaseQueryParams, + boolean protoFieldNameQueryParams) { + final ImmutableList.Builder queryParamMatchRules = + ImmutableList.builder(); + if (camelCaseQueryParams) { + queryParamMatchRules.add(HttpJsonTranscodingQueryParamMatchRule.LOWER_CAMEL_CASE); + } + if (protoFieldNameQueryParams) { + queryParamMatchRules.add(HttpJsonTranscodingQueryParamMatchRule.ORIGINAL_FIELD); + } + final HttpJsonTranscodingOptions options = + HttpJsonTranscodingOptions.builder() + .queryParamMatchRules(queryParamMatchRules.build()) + .build(); return new ServerExtension() { @Override protected void configure(ServerBuilder sb) throws Exception { final GrpcServiceBuilder grpcServiceBuilder = GrpcService.builder() .addService(new HttpJsonTranscodingTestService()) - .enableHttpJsonTranscoding(true); + .enableHttpJsonTranscoding(options); if (preservingProtoFieldNames) { grpcServiceBuilder.jsonMarshallerFactory(service -> GrpcJsonMarshaller .builder() @@ -743,6 +783,77 @@ void shouldBeIntegratedWithDocService() throws JsonProcessingException { "/foo/v2/messages/:message_id"); } + @Test + void shouldAcceptOnlyCamelCaseQueryParams() throws JsonProcessingException { + final QueryParams query = + QueryParams.builder() + .add("queryParameter", "testQuery") + .add("parentField.childField", "testChildField") + .add("parentField.childField2", "testChildField2") + .build(); + + final JsonNode response = + webClientCamelCaseQueryOnlyParameters.prepare() + .get("/v4/messages/1") + .queryParams(query) + .asJson(JsonNode.class) + .execute() + .content(); + assertThat(response.get("text").asText()).isEqualTo("1:testQuery:testChildField:testChildField2"); + + final QueryParams query2 = + QueryParams.builder() + .add("query_parameter", "testQuery") + .add("parent_field.child_field", "testChildField") + .add("parent_field.child_field_2", "testChildField2") + .build(); + + final JsonNode response2 = + webClientCamelCaseQueryOnlyParameters.prepare() + .get("/v4/messages/1") + .queryParams(query2) + .asJson(JsonNode.class) + .execute() + .content(); + // Disallow snake_case parameters. + assertThat(response2.get("text").asText()).isEqualTo("1:::"); + } + + @Test + void shouldAcceptBothCamelCaseAndSnakeCaseQueryParams() throws JsonProcessingException { + final QueryParams query = + QueryParams.builder() + .add("queryParameter", "testQuery") + .add("parentField.childField", "testChildField") + .add("parentField.childField2", "testChildField2") + .build(); + + final JsonNode response = + webClientCamelCaseQueryAndOriginalParameters.prepare() + .get("/v4/messages/1") + .queryParams(query) + .asJson(JsonNode.class) + .execute() + .content(); + assertThat(response.get("text").asText()).isEqualTo("1:testQuery:testChildField:testChildField2"); + + final QueryParams query2 = + QueryParams.builder() + .add("query_parameter", "testQuery") + .add("parent_field.child_field", "testChildField") + .add("parent_field.child_field_2", "testChildField2") + .build(); + + final JsonNode response2 = + webClientCamelCaseQueryAndOriginalParameters.prepare() + .get("/v4/messages/1") + .queryParams(query2) + .asJson(JsonNode.class) + .execute() + .content(); + assertThat(response2.get("text").asText()).isEqualTo("1:testQuery:testChildField:testChildField2"); + } + public static JsonNode findMethod(JsonNode methods, String name) { return StreamSupport.stream(methods.spliterator(), false) .filter(node -> node.get("name").asText().equals(name)) diff --git a/grpc/src/test/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingOptionsBuilderTest.java b/grpc/src/test/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingOptionsBuilderTest.java new file mode 100644 index 00000000000..146c23e2eb3 --- /dev/null +++ b/grpc/src/test/java/com/linecorp/armeria/server/grpc/HttpJsonTranscodingOptionsBuilderTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2022 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.armeria.server.grpc; + +import static com.linecorp.armeria.server.grpc.HttpJsonTranscodingQueryParamMatchRule.LOWER_CAMEL_CASE; +import static com.linecorp.armeria.server.grpc.HttpJsonTranscodingQueryParamMatchRule.ORIGINAL_FIELD; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; + +class HttpJsonTranscodingOptionsBuilderTest { + + @Test + void shouldDisallowEmptyNaming() { + assertThatThrownBy(() -> HttpJsonTranscodingOptions.builder() + .queryParamMatchRules(ImmutableList.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Can't set an empty queryParamMatchRules"); + } + + @Test + void shouldReturnConfiguredSettings() { + final HttpJsonTranscodingOptions withCamelCase = + HttpJsonTranscodingOptions.builder() + .queryParamMatchRules(LOWER_CAMEL_CASE) + .build(); + assertThat(withCamelCase.queryParamMatchRules()) + .containsExactly(LOWER_CAMEL_CASE); + + final HttpJsonTranscodingOptions onlyCamelCase = + HttpJsonTranscodingOptions.builder() + .queryParamMatchRules(ORIGINAL_FIELD) + .build(); + assertThat(onlyCamelCase.queryParamMatchRules()).containsExactly(ORIGINAL_FIELD); + + final HttpJsonTranscodingOptions onlyOriginalField = + HttpJsonTranscodingOptions.builder() + .queryParamMatchRules(ImmutableList.of(LOWER_CAMEL_CASE, + ORIGINAL_FIELD)) + .build(); + assertThat(onlyOriginalField.queryParamMatchRules()) + .containsExactlyInAnyOrder(ORIGINAL_FIELD, LOWER_CAMEL_CASE); + + final HttpJsonTranscodingOptions defaultOptions = HttpJsonTranscodingOptions.of(); + assertThat(defaultOptions.queryParamMatchRules()).containsExactly(ORIGINAL_FIELD); + } +} diff --git a/grpc/src/test/proto/com/linecorp/armeria/grpc/testing/transcoding.proto b/grpc/src/test/proto/com/linecorp/armeria/grpc/testing/transcoding.proto index ac1c0f92080..882af45e44c 100644 --- a/grpc/src/test/proto/com/linecorp/armeria/grpc/testing/transcoding.proto +++ b/grpc/src/test/proto/com/linecorp/armeria/grpc/testing/transcoding.proto @@ -43,6 +43,11 @@ service HttpJsonTranscodingTestService { get:"/v3/messages/{message_id}" }; } + rpc GetMessageV4(GetMessageRequestV4) returns (Message) { + option (google.api.http) = { + get:"/v4/messages/{message_id}" + }; + } rpc UpdateMessageV1(UpdateMessageRequestV1) returns (Message) { option (google.api.http) = { @@ -203,6 +208,16 @@ message GetMessageRequestV3 { repeated int64 revision = 2; // Mapped to URL query parameter `revision`. } +message GetMessageRequestV4 { + string message_id = 1; + string query_parameter = 2; + ParentMessage parent_field = 3; + message ParentMessage { + string child_field = 1; + string child_field_2 = 2; + } +} + message UpdateMessageRequestV1 { string message_id = 1; // mapped to the URL Message message = 2; // mapped to the body