Skip to content

Commit

Permalink
fix(cors): maps all unique operation mime types in cors preflight
Browse files Browse the repository at this point in the history
closes #2289
  • Loading branch information
jfkisafk authored and kstich committed May 22, 2024
1 parent b999809 commit 8d1c187
Show file tree
Hide file tree
Showing 5 changed files with 360 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import software.amazon.smithy.jsonschema.Schema;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.knowledge.TopDownIndex;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.node.ObjectNode;
import software.amazon.smithy.model.shapes.OperationShape;
import software.amazon.smithy.model.traits.CorsTrait;
Expand All @@ -45,6 +46,7 @@
import software.amazon.smithy.openapi.model.ResponseObject;
import software.amazon.smithy.utils.CaseUtils;
import software.amazon.smithy.utils.ListUtils;
import software.amazon.smithy.utils.SetUtils;

/**
* Adds CORS-preflight OPTIONS requests using mock API Gateway integrations.
Expand All @@ -70,6 +72,7 @@ final class AddCorsPreflightIntegration implements ApiGatewayMapper {
private static final Logger LOGGER = Logger.getLogger(AddCorsPreflightIntegration.class.getName());
private static final String API_GATEWAY_DEFAULT_ACCEPT_VALUE = "application/json";
private static final String INTEGRATION_EXTENSION = "x-amazon-apigateway-integration";
private static final String REQUEST_TEMPLATES_KEY = "requestTemplates";
private static final String PREFLIGHT_SUCCESS = "{\"statusCode\":200}";

@Override
Expand Down Expand Up @@ -229,6 +232,22 @@ private static ObjectNode createPreflightIntegration(Map<CorsHeader, String> hea
.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));
}

// Add a request template for every mime-type of every response.
for (OperationObject operation : pathItem.getOperations().values()) {
for (ResponseObject response : operation.getResponses().values()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,20 @@ public OpenApi after(Context context, OpenApi openapi) {

Node.assertEquals(result, expectedNode);
}

@Test
public void mapMultipleMimeTypesInRequestTemplates() {
Model model = Model.assembler(getClass().getClassLoader())
.discoverModels(getClass().getClassLoader())
.addImport(getClass().getResource("cors-with-multi-mime-types.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")));

Node.assertEquals(result, expectedNode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
{
"smithy": "2.0",
"shapes": {
"example.smithy#MyService": {
"type": "service",
"version": "2006-03-01",
"operations": [
{
"target": "example.smithy#MockPut"
},
{
"target": "example.smithy#MockGet"
}
],
"traits": {
"aws.protocols#restJson1": {},
"aws.auth#sigv4": {
"name": "myservice"
},
"smithy.api#cors": {
"origin": "https://www.example.com",
"maxAge": 86400,
"additionalAllowedHeaders": [
"X-Service-Input-Metadata"
],
"additionalExposedHeaders": [
"X-Service-Output-Metadata"
]
}
}
},
"example.smithy#MockGet": {
"type": "operation",
"output": {
"target": "example.smithy#MockOutput"
},
"traits": {
"aws.apigateway#mockIntegration": {
"passThroughBehavior": "never",
"requestTemplates": {
"application/json": "{\"statusCode\": 200}",
"application/x-www-form-urlencoded": "{\"statusCode\": 200}"
},
"responses": {
"default": {
"statusCode": "200",
"responseTemplates": {
"application/json": "{\"extendedRequestId\": \"$context.extendedRequestId\"}"
}
}
}
},
"smithy.api#http": {
"code": 200,
"method": "GET",
"uri": "/mock"
},
"smithy.api#readonly": {}
}
},
"example.smithy#MockPut": {
"type": "operation",
"output": {
"target": "example.smithy#MockOutput"
},
"traits": {
"aws.apigateway#mockIntegration": {
"passThroughBehavior": "never",
"requestTemplates": {
"text/plain": "{\"statusCode\": 200}",
"application/xml": "{\"statusCode\": 200}"
},
"responses": {
"default": {
"statusCode": "200",
"responseTemplates": {
"application/json": "{\"extendedRequestId\": \"$context.extendedRequestId\"}"
}
}
}
},
"smithy.api#http": {
"code": 201,
"method": "PUT",
"uri": "/mock"
},
"smithy.api#idempotent": {}
}
},
"example.smithy#MockOutput": {
"type": "structure",
"members": {
"extendedRequestId": {
"target": "smithy.api#String",
"traits": {
"smithy.api#required": {}
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
{
"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/xml": "{\"statusCode\":200}",
"application/x-www-form-urlencoded": "{\"statusCode\":200}",
"application/json": "{\"statusCode\":200}",
"text/plain": "{\"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'"
}
}
}
}
Loading

0 comments on commit 8d1c187

Please sign in to comment.