diff --git a/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonSerializerTest.java b/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonSerializerTest.java index dedb865d46..5eea9ac8de 100644 --- a/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonSerializerTest.java +++ b/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonSerializerTest.java @@ -286,7 +286,7 @@ public T getMemberValue(Schema member) { "name" : "Toucan", "color" : "red" }"""; - assertThat(result, equalTo(expectedFormat)); + assertThat(result.replace("\r", ""), equalTo(expectedFormat)); } } diff --git a/core/src/main/java/software/amazon/smithy/java/core/schema/TraitKey.java b/core/src/main/java/software/amazon/smithy/java/core/schema/TraitKey.java index f0b00ffa53..72a00f9fab 100644 --- a/core/src/main/java/software/amazon/smithy/java/core/schema/TraitKey.java +++ b/core/src/main/java/software/amazon/smithy/java/core/schema/TraitKey.java @@ -6,6 +6,7 @@ package software.amazon.smithy.java.core.schema; import java.util.concurrent.atomic.AtomicInteger; +import software.amazon.smithy.model.traits.CorsTrait; import software.amazon.smithy.model.traits.DefaultTrait; import software.amazon.smithy.model.traits.DocumentationTrait; import software.amazon.smithy.model.traits.EndpointTrait; @@ -102,6 +103,7 @@ protected TraitKey computeValue(Class clazz) { public static final TraitKey XML_ATTRIBUTE_TRAIT = TraitKey.get(XmlAttributeTrait.class); public static final TraitKey XML_FLATTENED_TRAIT = TraitKey.get(XmlFlattenedTrait.class); public static final TraitKey XML_NAMESPACE_TRAIT = TraitKey.get(XmlNamespaceTrait.class); + public static final TraitKey CORS_TRAIT = TraitKey.get(CorsTrait.class); private final Class traitClass; final int id; diff --git a/server/server-core/src/main/java/software/amazon/smithy/java/server/core/CorsHeaders.java b/server/server-core/src/main/java/software/amazon/smithy/java/server/core/CorsHeaders.java new file mode 100644 index 0000000000..9a35b1734d --- /dev/null +++ b/server/server-core/src/main/java/software/amazon/smithy/java/server/core/CorsHeaders.java @@ -0,0 +1,77 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.core; + +import static software.amazon.smithy.java.core.schema.TraitKey.CORS_TRAIT; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +public final class CorsHeaders { + + private CorsHeaders() {} + + private static final Map> BASE_CORS_HEADERS = Map.of( + "Access-Control-Allow-Methods", + List.of("GET, POST, PUT, DELETE, OPTIONS"), + "Access-Control-Allow-Headers", + List.of("*,Access-Control-Allow-Headers,Access-Control-Allow-Methods,Access-Control-Allow-Origin,Amz-Sdk-Invocation-Id,Amz-Sdk-Request,Authorization,Content-Length,Content-Type,X-Amz-User-Agent,X-Amzn-Trace-Id"), + "Access-Control-Max-Age", + List.of("600")); + + public static void addCorsHeaders(HttpJob job) { + if (!shouldAddCorsHeaders(job)) { + return; + } + + String requestOrigin = job.request().headers().firstValue("origin"); + String configuredOrigin = getConfiguredOrigin(job); + + if (!isOriginAllowed(configuredOrigin, requestOrigin)) { + return; + } + + job.response().headers().putHeaders(BASE_CORS_HEADERS); + job.response().headers().putHeader("Access-Control-Allow-Origin", List.of(requestOrigin)); + } + + private static boolean shouldAddCorsHeaders(HttpJob job) { + if (job.operation().getApiOperation().service() == null || + job.operation().getApiOperation().service().schema() == null + || + !job.operation().getApiOperation().service().schema().hasTrait(CORS_TRAIT)) { + return false; + } + return job.request().headers().hasHeader("origin"); + } + + private static String getConfiguredOrigin(HttpJob job) { + var corsTrait = job.operation() + .getApiOperation() + .service() + .schema() + .getTrait(CORS_TRAIT); + if (corsTrait != null) { + return corsTrait.getOrigin(); + } + return null; + } + + private static boolean isOriginAllowed(String configuredOrigin, String requestOrigin) { + if (configuredOrigin == null || requestOrigin == null) { + return false; + } + + if (configuredOrigin.equals("*")) { + return true; + } + + return Arrays.stream(configuredOrigin.split(",")) + .map(String::trim) + .anyMatch(origin -> origin.equalsIgnoreCase(requestOrigin)); + } +} diff --git a/server/server-core/src/test/java/software/amazon/smithy/java/server/core/CorsHeadersTest.java b/server/server-core/src/test/java/software/amazon/smithy/java/server/core/CorsHeadersTest.java new file mode 100644 index 0000000000..05eeded37a --- /dev/null +++ b/server/server-core/src/test/java/software/amazon/smithy/java/server/core/CorsHeadersTest.java @@ -0,0 +1,126 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.core; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import software.amazon.smithy.java.core.schema.ApiService; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.http.api.HttpHeaders; +import software.amazon.smithy.java.http.api.ModifiableHttpHeaders; +import software.amazon.smithy.java.server.Operation; +import software.amazon.smithy.model.traits.CorsTrait; + +public class CorsHeadersTest { + + record TestCase( + String name, + String configuredOrigin, + String requestOrigin, + boolean shouldHaveHeaders, + String expectedAllowOrigin) {} + + private static Stream corsTestCases() { + return Stream.of( + new TestCase( + "No request origin header", + "http://allowed-origin.com", + null, + false, + null), + new TestCase( + "Not allowed origin", + "http://allowed-origin.com", + "http://not-allowed-origin.com", + false, + null), + new TestCase( + "Multiple allowed origins", + "http://allowed-origin.com,http://other-allowed-origin.com", + "http://allowed-origin.com", + true, + "http://allowed-origin.com"), + new TestCase( + "Wildcard origin", + "*", + "http://any-origin.com", + true, + "http://any-origin.com"), + new TestCase( + "Exact match origin", + "http://allowed-origin.com", + "http://allowed-origin.com", + true, + "http://allowed-origin.com")); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("corsTestCases") + void testCorsHeaders(TestCase testCase) throws URISyntaxException { + // Create operation with configured CORS origin + Operation testOperation = Operation.of( + "TestOperation", + (input, context) -> new TestStructs.TestOutput(), + new TestStructs.TestApiOperation(new ApiService() { + @Override + public Schema schema() { + return Schema.structureBuilder( + CorsTrait.ID, + CorsTrait.builder().origin(testCase.configuredOrigin).build()).build(); + } + }), + new TestStructs.TestService()); + + // Create request headers + Map> headerMap = + testCase.requestOrigin != null ? Map.of("Origin", List.of(testCase.requestOrigin)) : Map.of(); + HttpHeaders requestHeaders = HttpHeaders.of(headerMap); + + // Create request + HttpRequest request = new HttpRequest( + requestHeaders, + new URI("http://test.com"), + "GET"); + + // Create response and job + HttpResponse response = new HttpResponse(new TestStructs.TestModifiableHttpHeaders()); + ServerProtocol protocol = new TestStructs.TestServerProtocol(List.of()); + HttpJob job = new HttpJob(testOperation, protocol, request, response); + + // Apply CORS headers + CorsHeaders.addCorsHeaders(job); + + // Verify headers + if (testCase.shouldHaveHeaders) { + assertContainsHeader(job.response().headers(), testCase.expectedAllowOrigin); + } else { + assertDoNotContainsHeader(job.response().headers()); + } + } + + private void assertContainsHeader(ModifiableHttpHeaders responseHeaders, String allowedOrigin) { + assertTrue(responseHeaders.hasHeader("Access-Control-Allow-Methods")); + assertTrue(responseHeaders.hasHeader("Access-Control-Allow-Headers")); + assertTrue(responseHeaders.hasHeader("Access-Control-Max-Age")); + assertTrue(responseHeaders.hasHeader("Access-Control-Allow-Origin")); + assertTrue(responseHeaders.allValues("Access-Control-Allow-Origin").contains(allowedOrigin)); + } + + private void assertDoNotContainsHeader(ModifiableHttpHeaders responseHeaders) { + assertFalse(responseHeaders.hasHeader("Access-Control-Allow-Methods")); + assertFalse(responseHeaders.hasHeader("Access-Control-Allow-Headers")); + assertFalse(responseHeaders.hasHeader("Access-Control-Max-Age")); + assertFalse(responseHeaders.hasHeader("Access-Control-Allow-Origin")); + } +} diff --git a/server/server-core/src/test/java/software/amazon/smithy/java/server/core/TestStructs.java b/server/server-core/src/test/java/software/amazon/smithy/java/server/core/TestStructs.java new file mode 100644 index 0000000000..e6e168b94e --- /dev/null +++ b/server/server-core/src/test/java/software/amazon/smithy/java/server/core/TestStructs.java @@ -0,0 +1,201 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.core; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.ApiService; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.schema.ShapeBuilder; +import software.amazon.smithy.java.core.serde.ShapeSerializer; +import software.amazon.smithy.java.core.serde.TypeRegistry; +import software.amazon.smithy.java.http.api.ModifiableHttpHeaders; +import software.amazon.smithy.java.server.Operation; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.model.shapes.ShapeId; + +public class TestStructs { + public static abstract class TestInput implements SerializableStruct { + @Override + public Schema schema() { + return null; + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + + } + + @Override + public T getMemberValue(Schema member) { + return null; + } + } + + public static class TestOutput implements SerializableStruct { + @Override + public Schema schema() { + return null; + } + + @Override + public void serializeMembers(ShapeSerializer serializer) { + + } + + @Override + public T getMemberValue(Schema member) { + return null; + } + } + + public static class TestService implements Service { + @Override + public Operation getOperation(String operationName) { + return null; + } + + @Override + public List> getAllOperations() { + return List.of(); + } + + @Override + public Schema schema() { + return null; + } + + @Override + public TypeRegistry typeRegistry() { + return null; + } + } + + public static class TestApiOperation implements ApiOperation { + ApiService apiService = null; + + public TestApiOperation(ApiService apiService) { + this.apiService = apiService; + } + + public TestApiOperation() {} + + @Override + public ShapeBuilder inputBuilder() { + return null; + } + + @Override + public ShapeBuilder outputBuilder() { + return null; + } + + @Override + public Schema schema() { + return null; + } + + @Override + public Schema inputSchema() { + return null; + } + + @Override + public Schema outputSchema() { + return null; + } + + @Override + public TypeRegistry errorRegistry() { + return null; + } + + @Override + public List effectiveAuthSchemes() { + return List.of(); + } + + @Override + public ApiService service() { + return apiService; + } + } + + public static class TestModifiableHttpHeaders implements ModifiableHttpHeaders { + Map> headers = new HashMap<>(); + + @Override + public void putHeader(String name, String value) { + headers.put(name, List.of(value)); + } + + @Override + public void putHeader(String name, List values) { + headers.put(name, values); + } + + @Override + public void removeHeader(String name) { + headers.remove(name); + } + + @Override + public List allValues(String name) { + return headers.getOrDefault(name, List.of()); + } + + @Override + public int size() { + return headers.size(); + } + + @Override + public Map> map() { + return headers; + } + + @Override + public Iterator>> iterator() { + return headers.entrySet().iterator(); + } + } + + public static class TestServerProtocol extends ServerProtocol { + + protected TestServerProtocol(List services) { + super(services); + } + + @Override + public ShapeId getProtocolId() { + return null; + } + + @Override + public ServiceProtocolResolutionResult resolveOperation( + ServiceProtocolResolutionRequest request, + List candidates + ) { + return null; + } + + @Override + public CompletableFuture deserializeInput(Job job) { + return null; + } + + @Override + protected CompletableFuture serializeOutput(Job job, SerializableStruct output, boolean isError) { + return null; + } + } +} diff --git a/server/server-netty/src/main/java/software/amazon/smithy/java/server/netty/HttpRequestHandler.java b/server/server-netty/src/main/java/software/amazon/smithy/java/server/netty/HttpRequestHandler.java index a5b168c2a8..56ed4f82e1 100644 --- a/server/server-netty/src/main/java/software/amazon/smithy/java/server/netty/HttpRequestHandler.java +++ b/server/server-netty/src/main/java/software/amazon/smithy/java/server/netty/HttpRequestHandler.java @@ -9,12 +9,18 @@ import io.netty.channel.Channel; import io.netty.channel.ChannelDuplexHandler; import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.http.*; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.LastHttpContent; import java.io.ByteArrayOutputStream; import java.net.URI; import software.amazon.smithy.java.framework.model.UnknownOperationException; import software.amazon.smithy.java.http.api.HttpHeaders; import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.java.server.core.CorsHeaders; import software.amazon.smithy.java.server.core.HttpJob; import software.amazon.smithy.java.server.core.HttpResponse; import software.amazon.smithy.java.server.core.Orchestrator; @@ -93,6 +99,7 @@ private void writeResponse(Channel channel, HttpJob job) { HttpVersion.HTTP_1_1, HttpResponseStatus.valueOf(job.response().getStatusCode()), Unpooled.wrappedBuffer(serializedValue.waitForByteBuffer())); + CorsHeaders.addCorsHeaders(job); response.headers().set(((NettyHttpHeaders) job.response().headers()).getNettyHeaders()); response.headers().set("content-length", serializedValue.contentLength()); if (serializedValue.contentType() != null) {