Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Maps all unique content-types for operations and set passthroughBehavior to never in CORS preflight integration #2290

Merged
merged 4 commits into from
May 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1550,6 +1550,29 @@ additionalAllowedCorsHeaders (``[string]``)
}
}


.. _generate-openapi-apigateway-setting-syncCorsPreflightIntegration:

syncCorsPreflightIntegration (``boolean``)
Set to true to sync CORS preflight integration request templates with all possible 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 `integration's passthroughBehavior`_ for CORS preflight integration
will be set to ``never``.

Binary types
============

Expand Down Expand Up @@ -1648,6 +1671,10 @@ additions during the OpenAPI conversion:
* Adds static CORS response headers to API Gateway "gateway" responses. These are added only when
no gateway responses are defined in the OpenAPI model.

.. note::
If :ref:`syncCorsPreflightIntegration <generate-openapi-apigateway-setting-syncCorsPreflightIntegration>` is
set to ``true``, the CORS preflight `integration's passthroughBehavior`_ will be set to ``never`` and the integration's
request templates will be synced with all possible content-types from other methods within the same path resource.

.. _authorizers:

Expand Down Expand Up @@ -2037,4 +2064,5 @@ The conversion process is highly extensible through
.. _Lambda authorizers: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions-authorizer.html
.. _API Gateway's API key usage plans: https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-api-usage-plans.html
.. _OpenAPI specification extension: https://spec.openapis.org/oas/v3.1.0#specification-extensions
.. _integration's passthroughBehavior: https://docs.aws.amazon.com/apigateway/latest/developerguide/integration-passthrough-behaviors.html
.. _gradle installed: https://gradle.org/install/
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 All @@ -95,7 +98,7 @@ private static PathItem addPreflightIntegration(
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();
}

Expand Down Expand Up @@ -170,15 +173,15 @@ private static String getAllowMethods(PathItem item) {
}

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())
.description("Handles CORS-preflight requests")
.operationId(createOperationId(path))
.putResponse("200", createPreflightResponse(headers))
.parameters(findPathParameters(pathItem))
.putExtension(INTEGRATION_EXTENSION, createPreflightIntegration(headers, pathItem))
.putExtension(INTEGRATION_EXTENSION, createPreflightIntegration(context, headers, pathItem))
.build();
}

Expand Down Expand Up @@ -214,21 +217,43 @@ private static ResponseObject createPreflightResponse(Map<CorsHeader, String> he
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.
for (Map.Entry<CorsHeader, String> e : headers.entrySet()) {
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")
// Passthrough behavior "never" will fail the request with unsupported content type more appropriately.
// https://docs.aws.amazon.com/apigateway/latest/developerguide/integration-passthrough-behaviors.html
.passThroughBehavior(isPreflightSynced ? "never" : "when_no_match")
jfkisafk marked this conversation as resolved.
Show resolved Hide resolved
.putResponse("default", responseBuilder.build())
.putRequestTemplate(API_GATEWAY_DEFAULT_ACCEPT_VALUE, 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(Node.objectNode());
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,37 @@ public OpenApi after(Context context, OpenApi openapi) {

Node.assertEquals(result, expectedNode);
}

@Test
public void withPreflightIntegrationSync() {
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"));
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-without-preflight-sync.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": {}
}
}
}
}
}
}
Loading
Loading