diff --git a/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/AddCorsToRestIntegrations.java b/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/AddCorsToRestIntegrations.java new file mode 100644 index 00000000000..417186405db --- /dev/null +++ b/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/AddCorsToRestIntegrations.java @@ -0,0 +1,169 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.smithy.aws.apigateway.openapi; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.logging.Logger; +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; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.openapi.fromsmithy.Context; +import software.amazon.smithy.openapi.model.OperationObject; +import software.amazon.smithy.utils.ListUtils; +import software.amazon.smithy.utils.Pair; + +/** + * Adds CORS headers to REST integrations. + * + *

If the service is a REST API that has the {@link CorsTrait}, then + * integration responses will include a statically computed + * Access-Control-Expose-Headers CORS headers that contains every header + * exposed by the integration, and Access-Control-Allow-Credentials header + * if the operation uses a security scheme that needs it, and and + * Access-Control-Allow-Origin header that is the result of + * {@link CorsTrait#getOrigin()}. + */ +final class AddCorsToRestIntegrations implements ApiGatewayMapper { + + private static final Logger LOGGER = Logger.getLogger(AddCorsToRestIntegrations.class.getName()); + private static final String RESPONSES_KEY = "responses"; + private static final String HEADER_PREFIX = "method.response.header."; + private static final String DEFAULT_KEY = "default"; + private static final String STATUS_CODE_KEY = "statusCode"; + private static final String RESPONSE_PARAMETERS_KEY = "responseParameters"; + + @Override + public List getApiTypes() { + return ListUtils.of(ApiGatewayConfig.ApiType.REST); + } + + @Override + public OperationObject updateOperation( + Context context, + OperationShape shape, + OperationObject operationObject, + String httpMethod, + String path + ) { + CorsTrait cors = context.getService().getTrait(CorsTrait.class).orElse(null); + + if (cors == null) { + return operationObject; + } + + return operationObject.getExtension(AddIntegrations.INTEGRATION_EXTENSION_NAME) + .flatMap(Node::asObjectNode) + .map(integrationObject -> updateOperation(context, shape, operationObject, cors, integrationObject)) + .orElse(operationObject); + } + + private OperationObject updateOperation( + Context context, + OperationShape shape, + OperationObject operationObject, + CorsTrait cors, + ObjectNode integrationObject + ) { + ObjectNode updated = updateIntegrationWithCors( + context, operationObject, shape, integrationObject, cors); + + return operationObject.toBuilder() + .putExtension(AddIntegrations.INTEGRATION_EXTENSION_NAME, updated) + .build(); + } + + private ObjectNode updateIntegrationWithCors( + Context context, + OperationObject operationObject, + OperationShape shape, + ObjectNode integrationNode, + CorsTrait cors + ) { + ObjectNode responses = integrationNode.getObjectMember(RESPONSES_KEY).orElse(Node.objectNode()); + + // Always include a "default" response that has the same HTTP response code. + if (!responses.getMember(DEFAULT_KEY).isPresent()) { + responses = responses.withMember(DEFAULT_KEY, Node.objectNode().withMember(STATUS_CODE_KEY, "200")); + } + + Map corsHeaders = new HashMap<>(); + corsHeaders.put(CorsHeader.ALLOW_ORIGIN, cors.getOrigin()); + if (context.usesHttpCredentials()) { + corsHeaders.put(CorsHeader.ALLOW_CREDENTIALS, "true"); + } + + LOGGER.finer(() -> String.format("Adding the following CORS headers to the API Gateway integration of %s: %s", + shape.getId(), corsHeaders)); + Set deducedHeaders = CorsHeader.deduceOperationResponseHeaders(context, operationObject, shape, cors); + LOGGER.fine(() -> String.format("Detected the following headers for operation %s: %s", + shape.getId(), deducedHeaders)); + + // Update each response by adding CORS headers. + responses = responses.getMembers().entrySet().stream() + .peek(entry -> LOGGER.fine(() -> String.format( + "Updating integration response %s for `%s` with CORS", entry.getKey(), shape.getId()))) + .map(entry -> Pair.of(entry.getKey(), updateIntegrationResponse( + shape, corsHeaders, deducedHeaders, entry.getValue().expectObjectNode()))) + .collect(ObjectNode.collect(Pair::getLeft, Pair::getRight)); + + return integrationNode.withMember(RESPONSES_KEY, responses); + } + + private ObjectNode updateIntegrationResponse( + OperationShape shape, + Map corsHeaders, + Set deduced, + ObjectNode response + ) { + Map responseHeaders = new HashMap<>(corsHeaders); + ObjectNode responseParams = response.getObjectMember(RESPONSE_PARAMETERS_KEY).orElseGet(Node::objectNode); + + // Created a sorted set of all headers exposed in the integration. + Set headersToExpose = new TreeSet<>(deduced); + responseParams.getStringMap().keySet().stream() + .filter(parameterName -> parameterName.startsWith(HEADER_PREFIX)) + .map(parameterName -> parameterName.substring(HEADER_PREFIX.length())) + .forEach(headersToExpose::add); + String headersToExposeString = String.join(",", headersToExpose); + + // If there are exposed headers, then add a new header to the integration + // that lists all of them. See https://fetch.spec.whatwg.org/#http-access-control-expose-headers. + if (!headersToExposeString.isEmpty()) { + responseHeaders.put(CorsHeader.EXPOSE_HEADERS, headersToExposeString); + LOGGER.fine(() -> String.format("Adding `%s` header to `%s` with value of `%s`", + CorsHeader.EXPOSE_HEADERS, shape.getId(), headersToExposeString)); + } + + if (responseHeaders.isEmpty()) { + LOGGER.fine(() -> "No headers are exposed by " + shape.getId()); + return response; + } + + // Create an updated response that injects Access-Control-Expose-Headers. + ObjectNode.Builder builder = responseParams.toBuilder(); + for (Map.Entry entry : responseHeaders.entrySet()) { + builder.withMember(HEADER_PREFIX + entry.getKey(), "'" + entry.getValue() + "'"); + } + + return response.withMember(RESPONSE_PARAMETERS_KEY, builder.build()); + } +} diff --git a/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/AddIntegrations.java b/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/AddIntegrations.java index 77a63fbc64b..5ca412912cb 100644 --- a/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/AddIntegrations.java +++ b/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/AddIntegrations.java @@ -15,12 +15,8 @@ package software.amazon.smithy.aws.apigateway.openapi; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.Set; -import java.util.TreeSet; import java.util.logging.Logger; import software.amazon.smithy.aws.apigateway.traits.IntegrationTrait; import software.amazon.smithy.aws.apigateway.traits.IntegrationTraitIndex; @@ -28,33 +24,20 @@ 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; import software.amazon.smithy.model.traits.Trait; import software.amazon.smithy.openapi.OpenApiException; import software.amazon.smithy.openapi.fromsmithy.Context; import software.amazon.smithy.openapi.model.OperationObject; import software.amazon.smithy.utils.ListUtils; -import software.amazon.smithy.utils.Pair; /** * Adds API Gateway integrations to operations. - * - *

If the service has the {@link CorsTrait}, then integration responses - * will include a statically computed Access-Control-Expose-Headers - * CORS headers that contains every header exposed by the integration, - * and Access-Control-Allow-Credentials header if the operation uses - * a security scheme that needs it, and and Access-Control-Allow-Origin - * header that is the result of {@link CorsTrait#getOrigin()}. */ final class AddIntegrations implements ApiGatewayMapper { + static final String INTEGRATION_EXTENSION_NAME = "x-amazon-apigateway-integration"; + private static final Logger LOGGER = Logger.getLogger(AddIntegrations.class.getName()); - private static final String EXTENSION_NAME = "x-amazon-apigateway-integration"; - private static final String RESPONSES_KEY = "responses"; - private static final String HEADER_PREFIX = "method.response.header."; - private static final String DEFAULT_KEY = "default"; - private static final String STATUS_CODE_KEY = "statusCode"; - private static final String RESPONSE_PARAMETERS_KEY = "responseParameters"; private static final String PASSTHROUGH_BEHAVIOR = "passthroughBehavior"; private static final String INCORRECT_PASSTHROUGH_BEHAVIOR = "passThroughBehavior"; @@ -74,7 +57,7 @@ public OperationObject updateOperation( IntegrationTraitIndex index = IntegrationTraitIndex.of(context.getModel()); return index.getIntegrationTrait(context.getService(), shape) .map(trait -> operation.toBuilder() - .putExtension(EXTENSION_NAME, createIntegration(context, operation, shape, trait)) + .putExtension(INTEGRATION_EXTENSION_NAME, createIntegration(context, shape, trait)) .build()) .orElseGet(() -> { LOGGER.warning("No API Gateway integration trait found for " + shape.getId()); @@ -83,21 +66,6 @@ public OperationObject updateOperation( } private ObjectNode createIntegration( - Context context, - OperationObject operationObject, - OperationShape shape, - Trait integration - ) { - ObjectNode integrationObject = getIntegrationAsObject(context, shape, integration); - return context.getService().getTrait(CorsTrait.class) - .map(cors -> { - LOGGER.fine(() -> String.format("Adding CORS to `%s` operation responses", shape.getId())); - return updateIntegrationWithCors(context, operationObject, shape, integrationObject, cors); - }) - .orElse(integrationObject); - } - - private static ObjectNode getIntegrationAsObject( Context context, OperationShape shape, Trait integration @@ -136,80 +104,4 @@ private static void validateTraitConfiguration(IntegrationTrait trait, + "'payloadFormatVersion' must be set on the aws.apigateway#integration trait."); } } - - private ObjectNode updateIntegrationWithCors( - Context context, - OperationObject operationObject, - OperationShape shape, - ObjectNode integrationNode, - CorsTrait cors - ) { - ObjectNode responses = integrationNode.getObjectMember(RESPONSES_KEY).orElse(Node.objectNode()); - - // Always include a "default" response that has the same HTTP response code. - if (!responses.getMember(DEFAULT_KEY).isPresent()) { - responses = responses.withMember(DEFAULT_KEY, Node.objectNode().withMember(STATUS_CODE_KEY, "200")); - } - - Map corsHeaders = new HashMap<>(); - corsHeaders.put(CorsHeader.ALLOW_ORIGIN, cors.getOrigin()); - if (context.usesHttpCredentials()) { - corsHeaders.put(CorsHeader.ALLOW_CREDENTIALS, "true"); - } - - LOGGER.finer(() -> String.format("Adding the following CORS headers to the API Gateway integration of %s: %s", - shape.getId(), corsHeaders)); - Set deducedHeaders = CorsHeader.deduceOperationResponseHeaders(context, operationObject, shape, cors); - LOGGER.fine(() -> String.format("Detected the following headers for operation %s: %s", - shape.getId(), deducedHeaders)); - - // Update each response by adding CORS headers. - responses = responses.getMembers().entrySet().stream() - .peek(entry -> LOGGER.fine(() -> String.format( - "Updating integration response %s for `%s` with CORS", entry.getKey(), shape.getId()))) - .map(entry -> Pair.of(entry.getKey(), updateIntegrationResponse( - shape, corsHeaders, deducedHeaders, entry.getValue().expectObjectNode()))) - .collect(ObjectNode.collect(Pair::getLeft, Pair::getRight)); - - return integrationNode.withMember(RESPONSES_KEY, responses); - } - - private ObjectNode updateIntegrationResponse( - OperationShape shape, - Map corsHeaders, - Set deduced, - ObjectNode response - ) { - Map responseHeaders = new HashMap<>(corsHeaders); - ObjectNode responseParams = response.getObjectMember(RESPONSE_PARAMETERS_KEY).orElseGet(Node::objectNode); - - // Created a sorted set of all headers exposed in the integration. - Set headersToExpose = new TreeSet<>(deduced); - responseParams.getStringMap().keySet().stream() - .filter(parameterName -> parameterName.startsWith(HEADER_PREFIX)) - .map(parameterName -> parameterName.substring(HEADER_PREFIX.length())) - .forEach(headersToExpose::add); - String headersToExposeString = String.join(",", headersToExpose); - - // If there are exposed headers, then add a new header to the integration - // that lists all of them. See https://fetch.spec.whatwg.org/#http-access-control-expose-headers. - if (!headersToExposeString.isEmpty()) { - responseHeaders.put(CorsHeader.EXPOSE_HEADERS, headersToExposeString); - LOGGER.fine(() -> String.format("Adding `%s` header to `%s` with value of `%s`", - CorsHeader.EXPOSE_HEADERS, shape.getId(), headersToExposeString)); - } - - if (responseHeaders.isEmpty()) { - LOGGER.fine(() -> "No headers are exposed by " + shape.getId()); - return response; - } - - // Create an updated response that injects Access-Control-Expose-Headers. - ObjectNode.Builder builder = responseParams.toBuilder(); - for (Map.Entry entry : responseHeaders.entrySet()) { - builder.withMember(HEADER_PREFIX + entry.getKey(), "'" + entry.getValue() + "'"); - } - - return response.withMember(RESPONSE_PARAMETERS_KEY, builder.build()); - } } diff --git a/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/ApiGatewayExtension.java b/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/ApiGatewayExtension.java index cd0bc2f9679..0e3563f7e17 100644 --- a/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/ApiGatewayExtension.java +++ b/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/ApiGatewayExtension.java @@ -32,11 +32,18 @@ public List getOpenApiMappers() { ApiGatewayMapper.wrap(new AddAuthorizers()), ApiGatewayMapper.wrap(new AddBinaryTypes()), ApiGatewayMapper.wrap(new AddIntegrations()), - ApiGatewayMapper.wrap(new AddRequestValidators()), - ApiGatewayMapper.wrap(new CloudFormationSubstitution()), + + // CORS For REST APIs + ApiGatewayMapper.wrap(new AddCorsToRestIntegrations()), ApiGatewayMapper.wrap(new AddCorsResponseHeaders()), ApiGatewayMapper.wrap(new AddCorsPreflightIntegration()), - ApiGatewayMapper.wrap(new AddCorsToGatewayResponses()) + ApiGatewayMapper.wrap(new AddCorsToGatewayResponses()), + + ApiGatewayMapper.wrap(new AddRequestValidators()), + ApiGatewayMapper.wrap(new CloudFormationSubstitution()), + + // HTTP API mappers. + ApiGatewayMapper.wrap(new CorsHttpIntegration()) ); } diff --git a/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/CorsHeader.java b/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/CorsHeader.java index b87de285ca9..e89efb5308c 100644 --- a/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/CorsHeader.java +++ b/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/CorsHeader.java @@ -21,7 +21,6 @@ import software.amazon.smithy.model.traits.CorsTrait; import software.amazon.smithy.model.traits.Trait; import software.amazon.smithy.openapi.fromsmithy.Context; -import software.amazon.smithy.openapi.fromsmithy.SecuritySchemeConverter; import software.amazon.smithy.openapi.model.OperationObject; import software.amazon.smithy.openapi.model.ResponseObject; @@ -56,10 +55,7 @@ static Set deduceOperationResponseHeaders( // and any headers explicitly modeled on the operation. Set result = new TreeSet<>(cors.getAdditionalExposedHeaders()); result.addAll(context.getOpenApiProtocol().getProtocolResponseHeaders(context, shape)); - - for (SecuritySchemeConverter converter : context.getSecuritySchemeConverters()) { - result.addAll(getSecuritySchemeResponseHeaders(context, converter)); - } + result.addAll(context.getAllSecuritySchemeResponseHeaders()); // Include all headers found in the generated OpenAPI response. for (ResponseObject responseObject : operationObject.getResponses().values()) { @@ -68,12 +64,4 @@ static Set deduceOperationResponseHeaders( return result; } - - private static Set getSecuritySchemeResponseHeaders( - Context context, - SecuritySchemeConverter converter - ) { - T t = context.getService().expectTrait(converter.getAuthSchemeType()); - return converter.getAuthResponseHeaders(context, t); - } } diff --git a/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/CorsHttpIntegration.java b/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/CorsHttpIntegration.java new file mode 100644 index 00000000000..b8056c7162b --- /dev/null +++ b/smithy-aws-apigateway-openapi/src/main/java/software/amazon/smithy/aws/apigateway/openapi/CorsHttpIntegration.java @@ -0,0 +1,238 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.smithy.aws.apigateway.openapi; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; +import java.util.logging.Logger; +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; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.openapi.fromsmithy.Context; +import software.amazon.smithy.openapi.model.OpenApi; +import software.amazon.smithy.openapi.model.OperationObject; +import software.amazon.smithy.openapi.model.ParameterObject; +import software.amazon.smithy.openapi.model.PathItem; +import software.amazon.smithy.openapi.model.Ref; +import software.amazon.smithy.openapi.model.ResponseObject; +import software.amazon.smithy.utils.ListUtils; +import software.amazon.smithy.utils.SetUtils; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Adds support for the API Gateway {@code x-amazon-apigateway-cors} + * extension for API Gateway HTTP APIs using values from the + * Smithy {@code cors} trait. + * + *

+ * + * @see API Gateway documentation + */ +@SmithyInternalApi +public final class CorsHttpIntegration implements ApiGatewayMapper { + + private static final Logger LOGGER = Logger.getLogger(CorsHttpIntegration.class.getName()); + private static final String CORS_HTTP_EXTENSION = "x-amazon-apigateway-cors"; + + @Override + public List getApiTypes() { + return ListUtils.of(ApiGatewayConfig.ApiType.HTTP); + } + + @Override + public OpenApi after(Context context, OpenApi openapi) { + return context.getService().getTrait(CorsTrait.class) + .map(corsTrait -> addCors(context, openapi, corsTrait)) + .orElse(openapi); + } + + private OpenApi addCors(Context context, OpenApi openapi, CorsTrait trait) { + // Use any existing x-amazon-apigateway-cors value, if present. + Node alreadySetCorsValue = openapi.getExtension(CORS_HTTP_EXTENSION) + .flatMap(Node::asObjectNode) + .orElse(null); + + if (alreadySetCorsValue != null) { + return openapi; + } + + Set allowedMethodsInService = getMethodsUsedInApi(context, openapi); + Set allowedRequestHeaders = getAllowedHeaders(context, trait, openapi); + Set exposedHeaders = getExposedHeaders(context, trait, openapi); + + ObjectNode.Builder corsObjectBuilder = Node.objectNodeBuilder() + .withMember("allowOrigins", Node.fromStrings(trait.getOrigin())) + .withMember("maxAge", trait.getMaxAge()) + .withMember("allowMethods", Node.fromStrings(allowedMethodsInService)) + .withMember("exposeHeaders", Node.fromStrings(exposedHeaders)) + .withMember("allowHeaders", Node.fromStrings(allowedRequestHeaders)); + + if (context.usesHttpCredentials()) { + corsObjectBuilder.withMember("allowCredentials", true); + } + + return openapi.toBuilder() + .putExtension(CORS_HTTP_EXTENSION, corsObjectBuilder.build()) + .build(); + } + + private Set getMethodsUsedInApi(Context context, OpenApi openApi) { + Set methods = new TreeSet<>(); + + if (!context.usesHttpCredentials()) { + LOGGER.info("Using * for Access-Control-Allow-Methods because the service does not use HTTP credentials"); + return SetUtils.of("*"); + } + + LOGGER.info("Generating a value for Access-Control-Allow-Methods because the service uses HTTP credentials"); + for (PathItem pathItem : openApi.getPaths().values()) { + for (String method : pathItem.getOperations().keySet()) { + // No need to call out OPTIONS as supported. + if (!method.equalsIgnoreCase("OPTIONS")) { + methods.add(method.toUpperCase(Locale.ENGLISH)); + } + } + } + + return methods; + } + + private Set getAllowedHeaders(Context context, CorsTrait corsTrait, OpenApi openApi) { + Set headers = new TreeSet<>(corsTrait.getAdditionalAllowedHeaders()); + + // If no additionalAllowedHeaders are set on the trait and the + // service doesn't use HTTP credentials, then the simplest way + // to ensure that every header is allowed is using "*", which + // allows all headers. This can't be used when HTTP credentials are + // used since "*" then becomes a literal "*". + if (headers.isEmpty() && !context.usesHttpCredentials()) { + LOGGER.info("Using * for Access-Control-Allow-Headers because the service does not use HTTP credentials"); + return SetUtils.of("*"); + } + + LOGGER.info("Generating a value for Access-Control-Allow-Headers because the service uses HTTP credentials"); + + // Note: not all headers used in a service are defined in the OpenAPI model. + // That's generally true for any service, but that assumption is made here + // too because security scheme and protocol headers are not defined on operations. + + // Allow request headers needed by security schemes. + headers.addAll(context.getAllSecuritySchemeRequestHeaders()); + + // Allow any protocol-specific request headers for each operation. + TopDownIndex topDownIndex = TopDownIndex.of(context.getModel()); + for (OperationShape operation : topDownIndex.getContainedOperations(context.getService())) { + headers.addAll(context.getOpenApiProtocol().getProtocolRequestHeaders(context, operation)); + } + + // Allow all of the headers that were added to the generated OpenAPI definition. + for (PathItem item : openApi.getPaths().values()) { + headers.addAll(getHeadersFromParameterRefs(openApi, item.getParameters())); + for (OperationObject operationObject : item.getOperations().values()) { + headers.addAll(getHeadersFromParameters(operationObject.getParameters())); + } + } + + return headers; + } + + private Set getExposedHeaders(Context context, CorsTrait corsTrait, OpenApi openApi) { + Set headers = new TreeSet<>(corsTrait.getAdditionalExposedHeaders()); + + // If not additionalExposedHeaders are set on the trait and the + // service doesn't use HTTP credentials, then the simplest way + // to ensure that every header is exposed is using "*", which + // exposes all headers. This can't be used when HTTP credentials are + // used since "*" then becomes a literal "*". + if (headers.isEmpty() && !context.usesHttpCredentials()) { + LOGGER.info("Using * for Access-Control-Expose-Headers because the service does not use HTTP credentials"); + return SetUtils.of("*"); + } + + LOGGER.info("Generating a value for Access-Control-Expose-Headers because the service uses HTTP credentials"); + + // Note: not all headers used in a service are defined in the OpenAPI model. + // That's generally true for any service, but that assumption is made here + // too because security scheme and protocol headers are not defined on operations. + + // Expose response headers populated by security schemes. + headers.addAll(context.getAllSecuritySchemeResponseHeaders()); + + // Expose any protocol-specific response headers for each operation. + TopDownIndex topDownIndex = TopDownIndex.of(context.getModel()); + for (OperationShape operation : topDownIndex.getContainedOperations(context.getService())) { + headers.addAll(context.getOpenApiProtocol().getProtocolResponseHeaders(context, operation)); + } + + // Expose all of the headers that were added to the generated OpenAPI definition. + for (PathItem item : openApi.getPaths().values()) { + for (OperationObject operationObject : item.getOperations().values()) { + for (ResponseObject responseObject : operationObject.getResponses().values()) { + headers.addAll(responseObject.getHeaders().keySet()); + } + } + } + + return headers; + } + + private Set getHeadersFromParameterRefs(OpenApi openApi, Collection> params) { + Collection resolved = new ArrayList<>(); + for (Ref ref : params) { + resolved.add(ref.deref(openApi.getComponents())); + } + return getHeadersFromParameters(resolved); + } + + private Set getHeadersFromParameters(Collection params) { + Set result = new TreeSet<>(); + for (ParameterObject param : params) { + if (param.getIn().filter(in -> in.equals("header")).isPresent()) { + param.getName().ifPresent(result::add); + } + } + return result; + } +} diff --git a/smithy-aws-apigateway-openapi/src/test/java/software/amazon/smithy/aws/apigateway/openapi/CorsHttpIntegrationTest.java b/smithy-aws-apigateway-openapi/src/test/java/software/amazon/smithy/aws/apigateway/openapi/CorsHttpIntegrationTest.java new file mode 100644 index 00000000000..fabe2b6fc07 --- /dev/null +++ b/smithy-aws-apigateway-openapi/src/test/java/software/amazon/smithy/aws/apigateway/openapi/CorsHttpIntegrationTest.java @@ -0,0 +1,90 @@ +/* + * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file 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 software.amazon.smithy.aws.apigateway.openapi; + +import java.util.Collections; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.CorsTrait; +import software.amazon.smithy.model.transform.ModelTransformer; +import software.amazon.smithy.openapi.OpenApiConfig; +import software.amazon.smithy.openapi.fromsmithy.OpenApiConverter; +import software.amazon.smithy.utils.IoUtils; + +public class CorsHttpIntegrationTest { + @Test + public void generatesCorsForHttpApis() { + Model model = Model.assembler(getClass().getClassLoader()) + .discoverModels(getClass().getClassLoader()) + .addImport(getClass().getResource("cors-model.json")) + .assemble() + .unwrap(); + + OpenApiConfig config = new OpenApiConfig(); + ApiGatewayConfig apiGatewayConfig = new ApiGatewayConfig(); + apiGatewayConfig.setApiGatewayType(ApiGatewayConfig.ApiType.HTTP); + config.putExtensions(apiGatewayConfig); + config.setService(ShapeId.from("example.smithy#MyService")); + + ObjectNode result = OpenApiConverter.create().config(config).convertToNode(model); + Node expectedNode = Node.parse(IoUtils.toUtf8String( + getClass().getResourceAsStream("http-api-cors.openapi.json"))); + + Node.assertEquals(result, expectedNode); + } + + @Test + public void generatesCorsForHttpApisWithNoExplicitValues() { + // This test replaces the trait found in http-api-cors.openapi.json so + // that no explicit allowed and exposed headers are provided, causing + // the conversion to "*" for the corresponding CORS headers rather + // than needing to enumerate them all. + Model model = Model.assembler(getClass().getClassLoader()) + .discoverModels(getClass().getClassLoader()) + .addImport(getClass().getResource("cors-model.json")) + .assemble() + .unwrap(); + + ModelTransformer transformer = ModelTransformer.create(); + model = transformer.mapShapes(model, shape -> { + return shape.getTrait(CorsTrait.class) + .map(cors -> { + cors = cors.toBuilder() + .additionalAllowedHeaders(Collections.emptySet()) + .additionalExposedHeaders(Collections.emptySet()) + .build(); + return Shape.shapeToBuilder(shape).addTrait(cors).build(); + }) + .orElse(shape); + }); + + OpenApiConfig config = new OpenApiConfig(); + ApiGatewayConfig apiGatewayConfig = new ApiGatewayConfig(); + apiGatewayConfig.setApiGatewayType(ApiGatewayConfig.ApiType.HTTP); + config.putExtensions(apiGatewayConfig); + config.setService(ShapeId.from("example.smithy#MyService")); + + ObjectNode result = OpenApiConverter.create().config(config).convertToNode(model); + Node expectedNode = Node.parse(IoUtils.toUtf8String( + getClass().getResourceAsStream("http-api-cors-wildcards.openapi.json"))); + + Node.assertEquals(result, expectedNode); + } +} diff --git a/smithy-aws-apigateway-openapi/src/test/resources/software/amazon/smithy/aws/apigateway/openapi/http-api-cors-wildcards.openapi.json b/smithy-aws-apigateway-openapi/src/test/resources/software/amazon/smithy/aws/apigateway/openapi/http-api-cors-wildcards.openapi.json new file mode 100644 index 00000000000..0a81b202c84 --- /dev/null +++ b/smithy-aws-apigateway-openapi/src/test/resources/software/amazon/smithy/aws/apigateway/openapi/http-api-cors-wildcards.openapi.json @@ -0,0 +1,230 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "MyService", + "version": "2006-03-01" + }, + "paths": { + "/payload": { + "get": { + "operationId": "ListPayloads", + "responses": { + "200": { + "description": "ListPayloads 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListPayloadsResponseContent" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "credentials": "arn:aws:iam::123456789012:role/MyServiceListPayloadsLambdaRole", + "httpMethod": "POST", + "payloadFormatVersion": "1.0", + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:123456789012:function:MyServiceListPayloads/invocations" + } + } + }, + "/payload/{id}": { + "delete": { + "operationId": "DeletePayload", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "DeletePayload response" + } + }, + "x-amazon-apigateway-integration": { + "credentials": "arn:aws:iam::123456789012:role/MyServiceDeletePayloadLambdaRole", + "httpMethod": "POST", + "payloadFormatVersion": "1.0", + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:123456789012:function:MyServiceDeletePayload/invocations" + } + }, + "get": { + "operationId": "GetPayload", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetPayload 200 response", + "headers": { + "X-Foo-Header": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/GetPayloadOutputPayload" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "credentials": "arn:aws:iam::123456789012:role/MyServiceGetPayloadLambdaRole", + "httpMethod": "POST", + "payloadFormatVersion": "1.0", + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:123456789012:function:MyServiceGetPayload/invocations" + } + }, + "put": { + "operationId": "PutPayload", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/PutPayloadInputPayload" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "number", + "format": "int32", + "nullable": true + } + }, + { + "name": "X-EnumString", + "in": "header", + "schema": { + "$ref": "#/components/schemas/EnumString" + } + }, + { + "name": "X-Foo-Header", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "PutPayload response" + } + }, + "x-amazon-apigateway-integration": { + "credentials": "arn:aws:iam::123456789012:role/MyServicePutPayloadLambdaRole", + "httpMethod": "POST", + "payloadFormatVersion": "1.0", + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:123456789012:function:MyServicePutPayload/invocations" + } + } + } + }, + "components": { + "schemas": { + "EnumString": { + "type": "string", + "enum": [ + "a", + "c" + ] + }, + "GetPayloadOutputPayload": { + "type": "string", + "format": "byte" + }, + "ListPayloadsResponseContent": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PayloadDescription" + } + } + } + }, + "PayloadDescription": { + "type": "object", + "properties": { + "createdAt": { + "type": "number" + }, + "id": { + "type": "string" + } + }, + "required": [ + "createdAt", + "id" + ] + }, + "PutPayloadInputPayload": { + "type": "string", + "format": "byte" + } + }, + "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-cors": { + "allowOrigins": [ + "https://www.example.com" + ], + "maxAge": 86400, + "allowMethods": [ + "*" + ], + "exposeHeaders": [ + "*" + ], + "allowHeaders": [ + "*" + ] + } +} diff --git a/smithy-aws-apigateway-openapi/src/test/resources/software/amazon/smithy/aws/apigateway/openapi/http-api-cors.openapi.json b/smithy-aws-apigateway-openapi/src/test/resources/software/amazon/smithy/aws/apigateway/openapi/http-api-cors.openapi.json new file mode 100644 index 00000000000..0f37889f46a --- /dev/null +++ b/smithy-aws-apigateway-openapi/src/test/resources/software/amazon/smithy/aws/apigateway/openapi/http-api-cors.openapi.json @@ -0,0 +1,238 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "MyService", + "version": "2006-03-01" + }, + "paths": { + "/payload": { + "get": { + "operationId": "ListPayloads", + "responses": { + "200": { + "description": "ListPayloads 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListPayloadsResponseContent" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "credentials": "arn:aws:iam::123456789012:role/MyServiceListPayloadsLambdaRole", + "httpMethod": "POST", + "payloadFormatVersion": "1.0", + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:123456789012:function:MyServiceListPayloads/invocations" + } + } + }, + "/payload/{id}": { + "delete": { + "operationId": "DeletePayload", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "204": { + "description": "DeletePayload response" + } + }, + "x-amazon-apigateway-integration": { + "credentials": "arn:aws:iam::123456789012:role/MyServiceDeletePayloadLambdaRole", + "httpMethod": "POST", + "payloadFormatVersion": "1.0", + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:123456789012:function:MyServiceDeletePayload/invocations" + } + }, + "get": { + "operationId": "GetPayload", + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "GetPayload 200 response", + "headers": { + "X-Foo-Header": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/GetPayloadOutputPayload" + } + } + } + } + }, + "x-amazon-apigateway-integration": { + "credentials": "arn:aws:iam::123456789012:role/MyServiceGetPayloadLambdaRole", + "httpMethod": "POST", + "payloadFormatVersion": "1.0", + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:123456789012:function:MyServiceGetPayload/invocations" + } + }, + "put": { + "operationId": "PutPayload", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/PutPayloadInputPayload" + } + } + } + }, + "parameters": [ + { + "name": "id", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "number", + "format": "int32", + "nullable": true + } + }, + { + "name": "X-EnumString", + "in": "header", + "schema": { + "$ref": "#/components/schemas/EnumString" + } + }, + { + "name": "X-Foo-Header", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "PutPayload response" + } + }, + "x-amazon-apigateway-integration": { + "credentials": "arn:aws:iam::123456789012:role/MyServicePutPayloadLambdaRole", + "httpMethod": "POST", + "payloadFormatVersion": "1.0", + "type": "aws_proxy", + "uri": "arn:aws:apigateway:us-west-2:lambda:path/2015-03-31/functions/arn:aws:lambda:us-west-2:123456789012:function:MyServicePutPayload/invocations" + } + } + } + }, + "components": { + "schemas": { + "EnumString": { + "type": "string", + "enum": [ + "a", + "c" + ] + }, + "GetPayloadOutputPayload": { + "type": "string", + "format": "byte" + }, + "ListPayloadsResponseContent": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PayloadDescription" + } + } + } + }, + "PayloadDescription": { + "type": "object", + "properties": { + "createdAt": { + "type": "number" + }, + "id": { + "type": "string" + } + }, + "required": [ + "createdAt", + "id" + ] + }, + "PutPayloadInputPayload": { + "type": "string", + "format": "byte" + } + }, + "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-cors": { + "allowOrigins": [ + "https://www.example.com" + ], + "maxAge": 86400, + "allowMethods": [ + "*" + ], + "exposeHeaders": [ + "X-Foo-Header", + "X-Service-Output-Metadata" + ], + "allowHeaders": [ + "Authorization", + "Date", + "X-Amz-Date", + "X-Amz-Security-Token", + "X-Amz-Target", + "X-EnumString", + "X-Foo-Header", + "X-Service-Input-Metadata" + ] + } +} diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/Context.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/Context.java index e16c6f42b22..9c8e17677a3 100644 --- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/Context.java +++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/Context.java @@ -19,7 +19,9 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.TreeMap; +import java.util.TreeSet; import software.amazon.smithy.jsonschema.JsonSchemaConverter; import software.amazon.smithy.jsonschema.Schema; import software.amazon.smithy.jsonschema.SchemaDocument; @@ -194,6 +196,54 @@ public boolean usesHttpCredentials() { return getSecuritySchemeConverters().stream().anyMatch(SecuritySchemeConverter::usesHttpCredentials); } + /** + * Gets an alphabetically sorted set of request headers used by every + * security scheme associated with the API. + * + *

This is useful when integrating with things like CORS.

+ * + * @return Returns the set of every request header used by every security scheme. + */ + public Set getAllSecuritySchemeRequestHeaders() { + Set headers = new TreeSet<>(); + for (SecuritySchemeConverter converter : getSecuritySchemeConverters()) { + headers.addAll(getSecuritySchemeRequestHeaders(this, converter)); + } + return headers; + } + + /** + * Gets an alphabetically sorted set of response headers used by every + * security scheme associated with the API. + * + *

This is useful when integrating with things like CORS.

+ * + * @return Returns the set of every response header used by every security scheme. + */ + public Set getAllSecuritySchemeResponseHeaders() { + Set headers = new TreeSet<>(); + for (SecuritySchemeConverter converter : getSecuritySchemeConverters()) { + headers.addAll(getSecuritySchemeResponseHeaders(this, converter)); + } + return headers; + } + + private static Set getSecuritySchemeRequestHeaders( + Context context, + SecuritySchemeConverter converter + ) { + T t = context.getService().expectTrait(converter.getAuthSchemeType()); + return converter.getAuthRequestHeaders(context, t); + } + + private static Set getSecuritySchemeResponseHeaders( + Context context, + SecuritySchemeConverter converter + ) { + T t = context.getService().expectTrait(converter.getAuthSchemeType()); + return converter.getAuthResponseHeaders(context, t); + } + /** * Gets all of the synthesized schemas that needed to be created while * generating the OpenAPI artifact.