fix(cors): applies the passthroughBehavior and mime-type sync behind a new flag

The new Open API setting is `syncCorsPreflightIntegration`.

Added the docs as well.

closes smithy-lang/smithy#2289
This commit is contained in:
🥷⚔️💀👻 2024-05-21 21:15:15 -07:00 committed by Kevin Stich
parent 8d1c187a8d
commit 58bd9bdfbb
7 changed files with 302 additions and 23 deletions

View File

@ -691,6 +691,29 @@ onErrorStatusConflict (``String``)
}
}
.. _generate-openapi-setting-syncCorsPreflightIntegration:
syncCorsPreflightIntegration (``boolean``)
Set to true to sync CORS preflight integration request templates with all combinations
of content-types from other methods within the same path resource.
.. code-block:: json
:caption: smithy-build.json
{
"version": "1.0",
"plugins": {
"openapi": {
"service": "example.weather#Weather",
"syncCorsPreflightIntegration": true
}
}
}
With this enabled, the ``passthroughBehavior`` for the CORS preflight integration
will be set to "never".
----------------------------------
JSON schema configuration settings
----------------------------------

View File

@ -98,7 +98,7 @@ final class AddCorsPreflightIntegration implements ApiGatewayMapper {
LOGGER.fine(() -> "Adding CORS-preflight OPTIONS request and API Gateway integration for " + path);
Map<CorsHeader, String> headers = deduceCorsHeaders(context, path, pathItem, corsTrait);
return pathItem.toBuilder()
.options(createPreflightOperation(path, pathItem, headers))
.options(createPreflightOperation(context, path, pathItem, headers))
.build();
}
@ -173,7 +173,7 @@ final class AddCorsPreflightIntegration implements ApiGatewayMapper {
}
private static OperationObject createPreflightOperation(
String path, PathItem pathItem, Map<CorsHeader, String> headers) {
Context<? extends Trait> context, String path, PathItem pathItem, Map<CorsHeader, String> headers) {
return OperationObject.builder()
.tags(ListUtils.of("CORS"))
.security(Collections.emptyList())
@ -181,7 +181,7 @@ final class AddCorsPreflightIntegration implements ApiGatewayMapper {
.operationId(createOperationId(path))
.putResponse("200", createPreflightResponse(headers))
.parameters(findPathParameters(pathItem))
.putExtension(INTEGRATION_EXTENSION, createPreflightIntegration(headers, pathItem))
.putExtension(INTEGRATION_EXTENSION, createPreflightIntegration(context, headers, pathItem))
.build();
}
@ -217,7 +217,8 @@ final class AddCorsPreflightIntegration implements ApiGatewayMapper {
return builder.build();
}
private static ObjectNode createPreflightIntegration(Map<CorsHeader, String> headers, PathItem pathItem) {
private static ObjectNode createPreflightIntegration(
Context<? extends Trait> context, Map<CorsHeader, String> headers, PathItem pathItem) {
IntegrationResponse.Builder responseBuilder = IntegrationResponse.builder().statusCode("200");
// Add each CORS header to the mock integration response.
@ -225,27 +226,30 @@ final class AddCorsPreflightIntegration implements ApiGatewayMapper {
responseBuilder.putResponseParameter("method.response.header." + e.getKey(), "'" + e.getValue() + "'");
}
boolean isPreflightSynced = Boolean.TRUE.equals(context.getConfig().getSyncCorsPreflightIntegration());
MockIntegrationTrait.Builder integration = MockIntegrationTrait.builder()
// See https://forums.aws.amazon.com/thread.jspa?threadID=256140
.contentHandling("CONVERT_TO_TEXT")
.passThroughBehavior("when_no_match")
.passThroughBehavior(isPreflightSynced ? "never" : "when_no_match")
.putResponse("default", responseBuilder.build())
.putRequestTemplate(API_GATEWAY_DEFAULT_ACCEPT_VALUE, PREFLIGHT_SUCCESS);
// Adds request template for every unique Content-Type supported by all path operations.
// This ensures that for Content-Type(s) other than 'application/json', the entire request payload
// is not sent to APIGW mock integration as stipulated by 'when_no_match' passthroughBehavior.
// APIGW throws an error if the mock integration request does not follow a set contract,
// example {"statusCode":200}.
for (OperationObject operation : pathItem.getOperations().values()) {
ObjectNode extensionNode = operation.getExtension(INTEGRATION_EXTENSION)
.flatMap(Node::asObjectNode)
.orElse(ObjectNode.EMPTY);
Set<String> mimeTypes = extensionNode.getObjectMember(REQUEST_TEMPLATES_KEY)
.map(ObjectNode::getStringMap)
.map(Map::keySet)
.orElse(SetUtils.of());
mimeTypes.forEach(mimeType -> integration.putRequestTemplate(mimeType, PREFLIGHT_SUCCESS));
if (isPreflightSynced) {
// Adds request template for every unique Content-Type supported by all path operations.
// This ensures that for Content-Type(s) other than 'application/json', the entire request payload
// is not sent to APIGW mock integration as stipulated by 'when_no_match' passthroughBehavior.
// APIGW throws an error if the mock integration request does not follow a set contract,
// example {"statusCode":200}.
for (OperationObject operation : pathItem.getOperations().values()) {
ObjectNode extensionNode = operation.getExtension(INTEGRATION_EXTENSION)
.flatMap(Node::asObjectNode)
.orElse(ObjectNode.EMPTY);
Set<String> mimeTypes = extensionNode.getObjectMember(REQUEST_TEMPLATES_KEY)
.map(ObjectNode::getStringMap)
.map(Map::keySet)
.orElse(SetUtils.of());
mimeTypes.forEach(mimeType -> integration.putRequestTemplate(mimeType, PREFLIGHT_SUCCESS));
}
}
// Add a request template for every mime-type of every response.

View File

@ -114,17 +114,34 @@ public class CorsTest {
}
@Test
public void mapMultipleMimeTypesInRequestTemplates() {
public void withPreflightIntegrationSync() {
Model model = Model.assembler(getClass().getClassLoader())
.discoverModels(getClass().getClassLoader())
.addImport(getClass().getResource("cors-with-multi-mime-types.json"))
.addImport(getClass().getResource("cors-with-multi-request-templates.json"))
.assemble()
.unwrap();
OpenApiConfig config = new OpenApiConfig();
config.setService(ShapeId.from("example.smithy#MyService"));
config.setSyncCorsPreflightIntegration(true);
ObjectNode result = OpenApiConverter.create().config(config).convertToNode(model);
Node expectedNode = Node.parse(IoUtils.toUtf8String(
getClass().getResourceAsStream("cors-with-preflight-sync.openapi.json")));
Node.assertEquals(result, expectedNode);
}
@Test
public void withoutPreflightIntegrationSync() {
Model model = Model.assembler(getClass().getClassLoader())
.discoverModels(getClass().getClassLoader())
.addImport(getClass().getResource("cors-with-multi-request-templates.json"))
.assemble()
.unwrap();
OpenApiConfig config = new OpenApiConfig();
config.setService(ShapeId.from("example.smithy#MyService"));
ObjectNode result = OpenApiConverter.create().config(config).convertToNode(model);
Node expectedNode = Node.parse(IoUtils.toUtf8String(
getClass().getResourceAsStream("cors-with-multi-mime-types.openapi.json")));
getClass().getResourceAsStream("cors-without-preflight-sync.openapi.json")));
Node.assertEquals(result, expectedNode);
}

View File

@ -0,0 +1,219 @@
{
"openapi": "3.0.2",
"info": {
"title": "MyService",
"version": "2006-03-01"
},
"paths": {
"/mock": {
"get": {
"operationId": "MockGet",
"responses": {
"200": {
"description": "MockGet 200 response",
"headers": {
"Access-Control-Allow-Origin": {
"schema": {
"type": "string"
}
},
"Access-Control-Expose-Headers": {
"schema": {
"type": "string"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MockGetResponseContent"
}
}
}
}
},
"x-amazon-apigateway-integration": {
"requestTemplates": {
"application/json": "{\"statusCode\": 200}",
"application/x-www-form-urlencoded": "{\"statusCode\": 200}"
},
"responses": {
"default": {
"statusCode": "200",
"responseTemplates": {
"application/json": "{\"extendedRequestId\": \"$context.extendedRequestId\"}"
},
"responseParameters": {
"method.response.header.Access-Control-Allow-Origin": "'https://www.example.com'",
"method.response.header.Access-Control-Expose-Headers": "'Content-Length,Content-Type,X-Amzn-Errortype,X-Amzn-Requestid,X-Service-Output-Metadata'"
}
}
},
"type": "mock",
"passthroughBehavior": "never"
}
},
"options": {
"description": "Handles CORS-preflight requests",
"operationId": "CorsMock",
"responses": {
"200": {
"description": "Canned response for CORS-preflight requests",
"headers": {
"Access-Control-Allow-Headers": {
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Methods": {
"schema": {
"type": "string"
}
},
"Access-Control-Allow-Origin": {
"schema": {
"type": "string"
}
},
"Access-Control-Max-Age": {
"schema": {
"type": "string"
}
}
}
}
},
"security": [],
"tags": [
"CORS"
],
"x-amazon-apigateway-integration": {
"contentHandling": "CONVERT_TO_TEXT",
"requestTemplates": {
"application/json": "{\"statusCode\":200}"
},
"responses": {
"default": {
"responseParameters": {
"method.response.header.Access-Control-Max-Age": "'86400'",
"method.response.header.Access-Control-Allow-Headers": "'Amz-Sdk-Invocation-Id,Amz-Sdk-Request,Authorization,Date,Host,X-Amz-Content-Sha256,X-Amz-Date,X-Amz-Security-Token,X-Amz-Target,X-Amz-User-Agent,X-Amzn-Trace-Id,X-Service-Input-Metadata'",
"method.response.header.Access-Control-Allow-Origin": "'https://www.example.com'",
"method.response.header.Access-Control-Allow-Methods": "'GET,PUT'"
},
"statusCode": "200"
}
},
"type": "mock",
"passthroughBehavior": "when_no_match"
}
},
"put": {
"operationId": "MockPut",
"responses": {
"201": {
"description": "MockPut 201 response",
"headers": {
"Access-Control-Allow-Origin": {
"schema": {
"type": "string"
}
},
"Access-Control-Expose-Headers": {
"schema": {
"type": "string"
}
}
},
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MockPutResponseContent"
}
}
}
}
},
"x-amazon-apigateway-integration": {
"requestTemplates": {
"text/plain": "{\"statusCode\": 200}",
"application/xml": "{\"statusCode\": 200}"
},
"responses": {
"default": {
"statusCode": "200",
"responseTemplates": {
"application/json": "{\"extendedRequestId\": \"$context.extendedRequestId\"}"
},
"responseParameters": {
"method.response.header.Access-Control-Allow-Origin": "'https://www.example.com'",
"method.response.header.Access-Control-Expose-Headers": "'Content-Length,Content-Type,X-Amzn-Errortype,X-Amzn-Requestid,X-Service-Output-Metadata'"
}
}
},
"type": "mock",
"passthroughBehavior": "never"
}
}
}
},
"components": {
"schemas": {
"MockGetResponseContent": {
"type": "object",
"properties": {
"extendedRequestId": {
"type": "string"
}
},
"required": [
"extendedRequestId"
]
},
"MockPutResponseContent": {
"type": "object",
"properties": {
"extendedRequestId": {
"type": "string"
}
},
"required": [
"extendedRequestId"
]
}
},
"securitySchemes": {
"aws.auth.sigv4": {
"type": "apiKey",
"description": "AWS Signature Version 4 authentication",
"name": "Authorization",
"in": "header",
"x-amazon-apigateway-authtype": "awsSigv4"
}
}
},
"security": [
{
"aws.auth.sigv4": [ ]
}
],
"x-amazon-apigateway-gateway-responses": {
"DEFAULT_4XX": {
"responseTemplates": {
"application/json": "{\"message\":$context.error.messageString}"
},
"responseParameters": {
"gatewayresponse.header.Access-Control-Allow-Origin": "'https://www.example.com'",
"gatewayresponse.header.Access-Control-Expose-Headers": "'X-Service-Output-Metadata'"
}
},
"DEFAULT_5XX": {
"responseTemplates": {
"application/json": "{\"message\":$context.error.messageString}"
},
"responseParameters": {
"gatewayresponse.header.Access-Control-Allow-Origin": "'https://www.example.com'",
"gatewayresponse.header.Access-Control-Expose-Headers": "'X-Service-Output-Metadata'"
}
}
}
}

View File

@ -101,6 +101,7 @@ public class OpenApiConfig extends JsonSchemaConfig {
private List<String> externalDocs = ListUtils.of(
"Homepage", "API Reference", "User Guide", "Developer Guide", "Reference", "Guide");
private boolean disableIntegerFormat = false;
private boolean syncCorsPreflightIntegration = false;
private ErrorStatusConflictHandlingStrategy onErrorStatusConflict;
private OpenApiVersion version = OpenApiVersion.VERSION_3_0_2;
@ -355,6 +356,21 @@ public class OpenApiConfig extends JsonSchemaConfig {
}
public boolean getSyncCorsPreflightIntegration() {
return this.syncCorsPreflightIntegration;
}
/**
* Set true to sync CORS preflight integration request templates with the other
* methods of the same path resource and set passthroughBehavior to "never".
*
* @param syncCorsPreflightIntegration True to match CORS preflight integration.
*/
public void setSyncCorsPreflightIntegration(boolean syncCorsPreflightIntegration) {
this.syncCorsPreflightIntegration = syncCorsPreflightIntegration;
}
public ErrorStatusConflictHandlingStrategy getOnErrorStatusConflict() {
return onErrorStatusConflict;
}