Skip to content

Commit d12e23d

Browse files
committed
Add Smithy RPC v2 JSON OpenAPI support
1 parent c1b8cbb commit d12e23d

File tree

9 files changed

+501
-4
lines changed

9 files changed

+501
-4
lines changed

docs/source-2.0/guides/model-translations/converting-to-openapi.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,27 @@ modified conflicting errors are then added to the combined response object.
725725
}
726726
}
727727
728+
.. _generate-openapi-setting-useStringsForArbitraryPrecision:
729+
730+
useStringsForArbitraryPrecision (``boolean``)
731+
=============================================
732+
733+
Set to ``true`` to use JSON strings instead of numbers to maintain arbitrary
734+
precision in cases where parsers don't handle it properly with numbers.
735+
736+
.. code-block:: json
737+
:caption: smithy-build.json
738+
739+
{
740+
"version": "1.0",
741+
"plugins": {
742+
"openapi": {
743+
"service": "example.weather#Weather",
744+
"useStringsForArbitraryPrecision": true
745+
}
746+
}
747+
}
748+
728749
----------------------------------
729750
JSON schema configuration settings
730751
----------------------------------

smithy-openapi/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,5 @@ dependencies {
1717
api(project(":smithy-jsonschema"))
1818
api(project(":smithy-aws-traits"))
1919
api(project(":smithy-openapi-traits"))
20+
api(project(":smithy-protocol-traits"))
2021
}

smithy-openapi/src/main/java/software/amazon/smithy/openapi/OpenApiConfig.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ public String toString() {
9898
private boolean syncCorsPreflightIntegration = false;
9999
private ErrorStatusConflictHandlingStrategy onErrorStatusConflict;
100100
private OpenApiVersion version = OpenApiVersion.VERSION_3_0_2;
101+
private boolean useStringsForArbitraryPrecision = false;
101102

102103
public OpenApiConfig() {
103104
super();
@@ -375,6 +376,20 @@ public void setOnErrorStatusConflict(ErrorStatusConflictHandlingStrategy onError
375376
this.onErrorStatusConflict = Objects.requireNonNull(onErrorStatusConflict);
376377
}
377378

379+
public boolean getUseStringsForArbitraryPrecision() {
380+
return useStringsForArbitraryPrecision;
381+
}
382+
383+
/**
384+
* Set to true to use JSON strings instead of numbers to maintain arbitrary precision
385+
* in cases where parsers don't handle it properly with numbers.
386+
*
387+
* @param useStringsForArbitraryPrecision True to use JSON strings for arbitrary precision numbers.
388+
*/
389+
public void setUseStringsForArbitraryPrecision(boolean useStringsForArbitraryPrecision) {
390+
this.useStringsForArbitraryPrecision = useStringsForArbitraryPrecision;
391+
}
392+
378393
/**
379394
* Creates an OpenApiConfig from a Node value.
380395
*

smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/CoreExtension.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import software.amazon.smithy.openapi.fromsmithy.mappers.SpecificationExtensionsMapper;
1717
import software.amazon.smithy.openapi.fromsmithy.mappers.UnsupportedTraits;
1818
import software.amazon.smithy.openapi.fromsmithy.protocols.AwsRestJson1Protocol;
19+
import software.amazon.smithy.openapi.fromsmithy.protocols.RpcV2JsonProtocolConverter;
1920
import software.amazon.smithy.openapi.fromsmithy.security.AwsV4Converter;
2021
import software.amazon.smithy.openapi.fromsmithy.security.HttpApiKeyAuthConverter;
2122
import software.amazon.smithy.openapi.fromsmithy.security.HttpBasicConverter;
@@ -39,7 +40,7 @@ public List<SecuritySchemeConverter<? extends Trait>> getSecuritySchemeConverter
3940

4041
@Override
4142
public List<OpenApiProtocol<? extends Trait>> getProtocols() {
42-
return ListUtils.of(new AwsRestJson1Protocol());
43+
return ListUtils.of(new AwsRestJson1Protocol(), new RpcV2JsonProtocolConverter());
4344
}
4445

4546
@Override

smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/OpenApiJsonSchemaMapper.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,18 @@ public Schema.Builder updateSchema(JsonSchemaMapperContext context, Schema.Build
5151
builder.putExtension("deprecated", Node.from(true));
5252
}
5353

54-
boolean useOpenApiIntegerType = config instanceof OpenApiConfig
54+
boolean configIsOpenApi = config instanceof OpenApiConfig;
55+
boolean useOpenApiIntegerType = configIsOpenApi
5556
&& ((OpenApiConfig) config).getUseIntegerType()
5657
&& !((OpenApiConfig) config).getDisableIntegerFormat();
5758

59+
// Force a string type for arbitrary precision numbers if the
60+
// setting is configured.
61+
if ((shape.isBigDecimalShape() || shape.isBigIntegerShape()) && configIsOpenApi
62+
&& ((OpenApiConfig) config).getUseStringsForArbitraryPrecision()) {
63+
builder.type("string");
64+
}
65+
5866
// Don't overwrite an existing format setting.
5967
if (!builder.getFormat().isPresent()) {
6068
// Only apply the int32/int64 formats if we map the type
@@ -68,7 +76,7 @@ public Schema.Builder updateSchema(JsonSchemaMapperContext context, Schema.Build
6876
updateFloatFormat(builder, config, "float");
6977
} else if (shape.isDoubleShape()) {
7078
updateFloatFormat(builder, config, "double");
71-
} else if (shape.isBlobShape() && config instanceof OpenApiConfig) {
79+
} else if (shape.isBlobShape() && configIsOpenApi) {
7280
handleFormatKeyword(builder, (OpenApiConfig) config);
7381
return builder;
7482
} else if (shape.isTimestampShape()) {
@@ -83,7 +91,7 @@ public Schema.Builder updateSchema(JsonSchemaMapperContext context, Schema.Build
8391
}
8492

8593
// Remove unsupported JSON Schema keywords.
86-
if (config instanceof OpenApiConfig) {
94+
if (configIsOpenApi) {
8795
OpenApiConfig openApiConfig = (OpenApiConfig) config;
8896
openApiConfig.getVersion().getUnsupportedKeywords().forEach(builder::disableProperty);
8997
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.openapi.fromsmithy.protocols;
6+
7+
import java.util.Map;
8+
import java.util.Optional;
9+
import java.util.Set;
10+
import java.util.TreeMap;
11+
import java.util.function.Function;
12+
import software.amazon.smithy.jsonschema.Schema;
13+
import software.amazon.smithy.model.Model;
14+
import software.amazon.smithy.model.knowledge.OperationIndex;
15+
import software.amazon.smithy.model.shapes.OperationShape;
16+
import software.amazon.smithy.model.shapes.Shape;
17+
import software.amazon.smithy.model.shapes.ShapeId;
18+
import software.amazon.smithy.model.shapes.StructureShape;
19+
import software.amazon.smithy.model.shapes.ToShapeId;
20+
import software.amazon.smithy.openapi.OpenApiConfig;
21+
import software.amazon.smithy.openapi.fromsmithy.Context;
22+
import software.amazon.smithy.openapi.fromsmithy.OpenApiProtocol;
23+
import software.amazon.smithy.openapi.model.MediaTypeObject;
24+
import software.amazon.smithy.openapi.model.OperationObject;
25+
import software.amazon.smithy.openapi.model.RequestBodyObject;
26+
import software.amazon.smithy.openapi.model.ResponseObject;
27+
import software.amazon.smithy.protocol.traits.Rpcv2JsonTrait;
28+
import software.amazon.smithy.utils.SetUtils;
29+
30+
/**
31+
* Converts the {@code smithy.protocols#rpcv2Json} protocol to OpenAPI.
32+
*
33+
* <p>Each operation is mapped to a POST request with a path of
34+
* {@code /service/{serviceName}/operation/{operationName}}, where
35+
* {@code serviceName} is the service shape name (without namespace).
36+
*/
37+
public final class RpcV2JsonProtocolConverter implements OpenApiProtocol<Rpcv2JsonTrait> {
38+
private static final String STATUS_CODE = "200";
39+
private static final String CONTENT_TYPE = "application/json";
40+
41+
private static final Set<String> REQUEST_HEADERS = SetUtils.of(
42+
// Since APIGW doesn't support event streaming, apply the 3 protocol headers.
43+
"Smithy-Protocol",
44+
"Content-Type",
45+
"Content-Length",
46+
// Used by clients for a purpose similar to the standard user-agent header.
47+
"X-Amz-User-Agent",
48+
// Used by clients configured to work with X-Ray.
49+
"X-Amzn-Trace-Id",
50+
// Used by clients for adaptive retry behavior.
51+
"Amz-Sdk-Request",
52+
"Amz-Sdk-Invocation-Id");
53+
54+
private static final Set<String> RESPONSE_HEADERS = SetUtils.of(
55+
// Since APIGW doesn't support event streaming, apply the 3 protocol headers.
56+
"Smithy-Protocol",
57+
"Content-Type",
58+
"Content-Length",
59+
// Used to identify a given request/response, primarily for debugging.
60+
"X-Amzn-Requestid");
61+
62+
@Override
63+
public Class<Rpcv2JsonTrait> getProtocolType() {
64+
return Rpcv2JsonTrait.class;
65+
}
66+
67+
@Override
68+
public void updateDefaultSettings(Model model, OpenApiConfig config) {
69+
config.setUseStringsForArbitraryPrecision(true);
70+
}
71+
72+
/**
73+
* Each operation will have a separate path in the format /service/{serviceName}/operation/{operationName}
74+
*/
75+
@Override
76+
public Optional<OpenApiProtocol.Operation> createOperation(
77+
Context<Rpcv2JsonTrait> context,
78+
OperationShape operation
79+
) {
80+
OperationObject.Builder builder = OperationObject.builder()
81+
.operationId(context.getService().getContextualName(operation));
82+
createRequestBody(context, operation).ifPresent(builder::requestBody);
83+
createResponseBody(context, operation).forEach(builder::putResponse);
84+
return Optional.of(OpenApiProtocol.Operation.create(
85+
getOperationMethod(context, operation),
86+
getOperationUri(context, operation),
87+
builder));
88+
}
89+
90+
@Override
91+
public String getOperationResponseStatusCode(Context<Rpcv2JsonTrait> context, ToShapeId operationOrError) {
92+
if (context.getModel().expectShape(operationOrError.toShapeId()).isOperationShape()) {
93+
return STATUS_CODE;
94+
}
95+
return OpenApiProtocol.super.getOperationResponseStatusCode(context, operationOrError);
96+
}
97+
98+
@Override
99+
public String getOperationMethod(Context<Rpcv2JsonTrait> context, OperationShape operation) {
100+
return "POST";
101+
}
102+
103+
@Override
104+
public String getOperationUri(Context<Rpcv2JsonTrait> context, OperationShape operation) {
105+
return "/service/" + context.getService().getId().getName()
106+
+ "/operation/" + context.getService().getContextualName(operation);
107+
}
108+
109+
@Override
110+
public Set<String> getProtocolRequestHeaders(Context<Rpcv2JsonTrait> context, OperationShape operationShape) {
111+
return REQUEST_HEADERS;
112+
}
113+
114+
@Override
115+
public Set<String> getProtocolResponseHeaders(Context<Rpcv2JsonTrait> context, OperationShape operationShape) {
116+
return RESPONSE_HEADERS;
117+
}
118+
119+
private Map<String, ResponseObject> createResponseBody(
120+
Context<Rpcv2JsonTrait> context,
121+
OperationShape operation
122+
) {
123+
Map<String, ResponseObject> result = new TreeMap<>();
124+
125+
if (operation.getOutput().isPresent()) {
126+
result.put(STATUS_CODE,
127+
buildResponseObject(context,
128+
operation.getOutputShape(),
129+
operation,
130+
"ResponseContent"));
131+
132+
OperationIndex operationIndex = OperationIndex.of(context.getModel());
133+
for (StructureShape error : operationIndex.getErrors(operation)) {
134+
String errorCode = context.getOpenApiProtocol().getOperationResponseStatusCode(context, error);
135+
result.put(errorCode, buildResponseObject(context, error.toShapeId(), error, "ErrorContent"));
136+
}
137+
}
138+
return result;
139+
}
140+
141+
private ResponseObject buildResponseObject(
142+
Context<Rpcv2JsonTrait> context,
143+
ShapeId shape,
144+
Shape operationOrError,
145+
String suffix
146+
) {
147+
Schema schema = convertToSchema(context, shape);
148+
MediaTypeObject mediaTypeObject = createMediaTypeObject(context, schema, operationOrError, suffix);
149+
150+
return ResponseObject.builder()
151+
.description("Response Object")
152+
.putContent(CONTENT_TYPE, mediaTypeObject)
153+
.build();
154+
}
155+
156+
private Optional<RequestBodyObject> createRequestBody(
157+
Context<Rpcv2JsonTrait> context,
158+
OperationShape operation
159+
) {
160+
if (operation.getInput().isPresent()) {
161+
Schema schema = convertToSchema(context, operation.getInputShape());
162+
MediaTypeObject mediaTypeObject = createMediaTypeObject(context, schema, operation, "RequestContent");
163+
164+
return Optional.of(RequestBodyObject.builder()
165+
.description("Request Object")
166+
.putContent(CONTENT_TYPE, mediaTypeObject)
167+
.build());
168+
}
169+
return Optional.empty();
170+
}
171+
172+
private MediaTypeObject createMediaTypeObject(
173+
Context<Rpcv2JsonTrait> context,
174+
Schema schema,
175+
Shape operationOrError,
176+
String suffix
177+
) {
178+
return getMediaTypeObject(context, schema, operationOrError, shape -> {
179+
String shapeName = context.getService().getContextualName(shape.getId());
180+
return shapeName + suffix;
181+
});
182+
}
183+
184+
private MediaTypeObject getMediaTypeObject(
185+
Context<Rpcv2JsonTrait> context,
186+
Schema schema,
187+
Shape shape,
188+
Function<Shape, String> createSynthesizedName
189+
) {
190+
String synthesizedName = createSynthesizedName.apply(shape);
191+
String pointer = context.putSynthesizedSchema(synthesizedName, schema);
192+
return MediaTypeObject.builder()
193+
.schema(Schema.builder().ref(pointer).build())
194+
.build();
195+
}
196+
197+
private Schema convertToSchema(Context<Rpcv2JsonTrait> context, ShapeId shape) {
198+
StructureShape containerShape = context.getModel().expectShape(shape, StructureShape.class);
199+
return context.getJsonSchemaConverter()
200+
.convertShape(containerShape)
201+
.getRootSchema();
202+
}
203+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
package software.amazon.smithy.openapi.fromsmithy.protocols;
6+
7+
import java.io.InputStream;
8+
import org.junit.jupiter.api.Test;
9+
import software.amazon.smithy.model.Model;
10+
import software.amazon.smithy.model.node.Node;
11+
import software.amazon.smithy.model.node.ObjectNode;
12+
import software.amazon.smithy.model.shapes.ShapeId;
13+
import software.amazon.smithy.openapi.OpenApiConfig;
14+
import software.amazon.smithy.openapi.fromsmithy.OpenApiConverter;
15+
import software.amazon.smithy.utils.IoUtils;
16+
17+
public class SmithyRpcV2JsonProtocolTest {
18+
19+
@Test
20+
public void convertsExamples() {
21+
Model model = Model.assembler()
22+
.addImport(getClass().getResource("rpc-v2-json.smithy"))
23+
.discoverModels()
24+
.assemble()
25+
.unwrap();
26+
OpenApiConfig config = new OpenApiConfig();
27+
config.setService(ShapeId.from("example.smithy#MyService"));
28+
ObjectNode result = OpenApiConverter.create()
29+
.config(config)
30+
.convertToNode(model);
31+
InputStream openApiStream = getClass().getResourceAsStream("rpc-v2-json.openapi.json");
32+
33+
if (openApiStream == null) {
34+
throw new RuntimeException("OpenAPI model not found for test case: rpc-v2-json.openapi.json");
35+
} else {
36+
Node expectedNode = Node.parse(IoUtils.toUtf8String(openApiStream));
37+
Node.assertEquals(result, expectedNode);
38+
}
39+
}
40+
}

0 commit comments

Comments
 (0)