diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de2a09d00fd..be2fa851be9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -36,6 +36,7 @@ managed-dekorate = "1.0.3" managed-elasticsearch = "7.16.3" managed-ignite = "2.13.0" managed-junit5 = "5.9.1" +managed-junit-platform="1.9.1" managed-kotlin = "1.6.21" managed-kotlin-coroutines = "1.5.1" managed-google-function-framework = "1.0.4" @@ -401,6 +402,9 @@ jsr107 = { module = "org.jsr107.ri:cache-ri-impl", version.ref = "jsr107" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "managed-junit5" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "managed-junit5" } +junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "managed-junit5" } +junit-platform-engine = { module = "org.junit.platform:junit-platform-suite-engine", version.ref = "managed-junit-platform" } + junit-vintage = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "managed-junit5" } jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" } diff --git a/http-server-tck/build.gradle.kts b/http-server-tck/build.gradle.kts new file mode 100644 index 00000000000..f8a0ff7a35b --- /dev/null +++ b/http-server-tck/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("io.micronaut.build.internal.convention-library") +} +repositories { + mavenCentral() +} + +dependencies { + annotationProcessor(projects.injectJava) + annotationProcessor(projects.validation) + implementation(projects.validation) + implementation(projects.runtime) + implementation(projects.inject) + api(projects.httpServer) + api(libs.junit.jupiter.api) + api(libs.junit.jupiter.params) + api(libs.managed.reactor) +} + +java { + sourceCompatibility = JavaVersion.toVersion("1.8") + targetCompatibility = JavaVersion.toVersion("1.8") +} +micronautBuild { + binaryCompatibility { + enabled.set(false) + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/AssertionUtils.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/AssertionUtils.java new file mode 100644 index 00000000000..e3a46348a01 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/AssertionUtils.java @@ -0,0 +1,123 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.api.function.ThrowingSupplier; + +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Utility class used to perform assertions. + * @author Sergio del Amo + * @since 3.8.0 + */ +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only +}) +@Experimental +public final class AssertionUtils { + + private AssertionUtils() { + + } + + public static void assertThrows(@NonNull ServerUnderTest server, + @NonNull HttpRequest request, + @NonNull HttpResponseAssertion assertion) { + assertThrows(server, request, assertion.getHttpStatus(), assertion.getBody(), assertion.getHeaders()); + } + + public static void assertThrows(@NonNull ServerUnderTest server, + @NonNull HttpRequest request, + @NonNull HttpStatus expectedStatus, + @Nullable String expectedBody, + @Nullable Map expectedHeaders) { + Executable e = expectedBody != null ? + () -> server.exchange(request, String.class) : + () -> server.exchange(request); + HttpClientResponseException thrown = Assertions.assertThrows(HttpClientResponseException.class, e); + HttpResponse response = thrown.getResponse(); + assertEquals(expectedStatus, response.getStatus()); + assertHeaders(response, expectedHeaders); + assertBody(response, expectedBody); + } + + public static void assertDoesNotThrow(@NonNull ServerUnderTest server, + @NonNull HttpRequest request, + @NonNull HttpResponseAssertion assertion) { + assertDoesNotThrow(server, request, assertion.getHttpStatus(), assertion.getBody(), assertion.getHeaders()); + } + + public static void assertDoesNotThrow(@NonNull ServerUnderTest server, + @NonNull HttpRequest request, + @NonNull HttpStatus expectedStatus, + @Nullable String expectedBody, + @Nullable Map expectedHeaders) { + ThrowingSupplier> executable = expectedBody != null ? + () -> server.exchange(request, String.class) : + () -> server.exchange(request); + HttpResponse response = Assertions.assertDoesNotThrow(executable); + assertEquals(expectedStatus, response.getStatus()); + assertHeaders(response, expectedHeaders); + assertBody(response, expectedBody); + } + + private static void assertBody(@NonNull HttpResponse response, @Nullable String expectedBody) { + if (expectedBody != null) { + Optional bodyOptional = response.getBody(String.class); + assertTrue(bodyOptional.isPresent()); + bodyOptional.ifPresent(body -> assertTrue(body.contains(expectedBody))); + } + } + + private static void assertHeaders(@NonNull HttpResponse response, @Nullable Map expectedHeaders) { + + if (expectedHeaders != null) { + for (Map.Entry expectedHeadersEntrySet : expectedHeaders.entrySet()) { + String headerName = expectedHeadersEntrySet.getKey(); + Optional headerOptional = response.getHeaders().getFirst(headerName); + assertTrue(headerOptional.isPresent(), () -> "Header " + headerName + " not present"); + headerOptional.ifPresent(headerValue -> { + String expectedValue = expectedHeadersEntrySet.getValue(); + if (headerName.equals(HttpHeaders.CONTENT_TYPE)) { + if (headerValue.contains(";charset=")) { + assertTrue(headerValue.startsWith(expectedValue), () -> "header value " + headerValue + " does not start with " + expectedValue); + } else { + assertEquals(expectedValue, headerOptional.get()); + } + } else { + assertEquals(expectedValue, headerOptional.get()); + } + }); + } + + } + } + +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/EmbeddedServerUnderTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/EmbeddedServerUnderTest.java new file mode 100644 index 00000000000..115feccf611 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/EmbeddedServerUnderTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.runtime.server.EmbeddedServer; + +import java.io.IOException; +import java.util.Map; +import java.util.Optional; + +/** + * {@link ServerUnderTest} implementation for {@link EmbeddedServer}. + * @author Sergio del Amo + * @since 3.0.0 + */ +@Experimental +public class EmbeddedServerUnderTest implements ServerUnderTest { + + private EmbeddedServer embeddedServer; + private HttpClient httpClient; + private BlockingHttpClient client; + + public EmbeddedServerUnderTest(@NonNull Map properties) { + this.embeddedServer = ApplicationContext.run(EmbeddedServer.class, properties); + } + + @Override + public HttpResponse exchange(HttpRequest request, Argument bodyType) { + return getBlockingHttpClient().exchange(request, bodyType); + } + + @Override + public ApplicationContext getApplicationContext() { + return embeddedServer.getApplicationContext(); + } + + @Override + public void close() throws IOException { + if (httpClient != null) { + httpClient.close(); + } + if (embeddedServer != null) { + embeddedServer.close(); + } + } + + @Override + @NonNull + public Optional getPort() { + return Optional.ofNullable(embeddedServer).map(EmbeddedServer::getPort); + } + + @NonNull + private HttpClient getHttpClient() { + if (httpClient == null) { + this.httpClient = getApplicationContext().createBean(HttpClient.class, embeddedServer.getURL()); + } + return httpClient; + } + + @NonNull + private BlockingHttpClient getBlockingHttpClient() { + if (client == null) { + this.client = getHttpClient().toBlocking(); + } + return client; + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/EmbeddedServerUnderTestProvider.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/EmbeddedServerUnderTestProvider.java new file mode 100644 index 00000000000..f6708f64620 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/EmbeddedServerUnderTestProvider.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.NonNull; + +import java.util.Map; + +/** + * {@link ServerUnderTestProvider} implemntation which returns an instance of {@link EmbeddedServerUnderTest}. + * @author Sergio del Amo + * @since 3.8.0 + */ +@Experimental +public class EmbeddedServerUnderTestProvider implements ServerUnderTestProvider { + @Override + @NonNull + public ServerUnderTest getServer(@NonNull Map properties) { + return new EmbeddedServerUnderTest(properties); + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/HttpResponseAssertion.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/HttpResponseAssertion.java new file mode 100644 index 00000000000..690a43c7dd0 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/HttpResponseAssertion.java @@ -0,0 +1,120 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.http.HttpStatus; + +import java.util.Map; +import java.util.Objects; + +/** + * Utility class to verify assertions given an HTTP Response. + * @author Sergio del Amo + * @since 3.8.0 + */ +@Experimental +public final class HttpResponseAssertion { + + private final HttpStatus httpStatus; + private final Map headers; + private final String body; + + private HttpResponseAssertion(HttpStatus httpStatus, Map headers, String body) { + this.httpStatus = httpStatus; + this.headers = headers; + this.body = body; + } + + /** + * + * @return Expected HTTP Response Status + */ + public HttpStatus getHttpStatus() { + return httpStatus; + } + + /** + * + * @return Expected HTTP Response Headers + */ + public Map getHeaders() { + return headers; + } + + /** + * + * @return Expected HTTP Response body + */ + public String getBody() { + return body; + } + + /** + * + * @return Creates an instance of {@link HttpResponseAssertion.Builder}. + */ + public static HttpResponseAssertion.Builder builder() { + return new HttpResponseAssertion.Builder(); + } + + /** + * HTTP Response Assertion Builder. + */ + public static class Builder { + private HttpStatus httpStatus; + private Map headers; + private String body; + + /** + * + * @param headers HTTP Headers + * @return HTTP Response Assertion Builder + */ + public Builder headers(Map headers) { + this.headers = headers; + return this; + } + + /** + * + * @param body Response Body + * @return HTTP Response Assertion Builder + */ + public Builder body(String body) { + this.body = body; + return this; + } + + /** + * + * @param httpStatus Response's HTTP Status + * @return HTTP Response Assertion Builder + */ + public Builder status(HttpStatus httpStatus) { + this.httpStatus = httpStatus; + return this; + } + + /** + * + * @return HTTP Response Assertion + */ + public HttpResponseAssertion build() { + return new HttpResponseAssertion(Objects.requireNonNull(httpStatus), headers, body); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/ServerUnderTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/ServerUnderTest.java new file mode 100644 index 00000000000..0712fc9633b --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/ServerUnderTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck; + +import io.micronaut.context.ApplicationContextProvider; +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; + +import java.io.Closeable; +import java.util.Optional; + +/** + * An API for a Micronaut HTTP Server under test. An implementation can be Netty or AWS Lambda Handler. + * @author Sergio del Amo + * @since 1.8.0 + */ +@Experimental +public interface ServerUnderTest extends ApplicationContextProvider, Closeable, AutoCloseable { + + /* + * Perform an HTTP request for the given request against the server under test and returns the the full HTTP response + * @param request The {@link HttpRequest} to execute + * @param The request body type + * @param The response body type + * @return The full {@link HttpResponse} object + * @throws HttpClientResponseException when an error status is returned + */ + default HttpResponse exchange(HttpRequest request) { + return exchange(request, (Argument) null); + } + + /* + * Perform an HTTP request for the given request against the server under test and returns the full HTTP response + * @param request The {@link HttpRequest} to execute + * @param bodyType The body type + * @param The request body type + * @param The response body type + * @return The full {@link HttpResponse} object + * @throws HttpClientResponseException when an error status is returned + */ + default HttpResponse exchange(HttpRequest request, Class bodyType) { + return exchange(request, Argument.of(bodyType)); + } + + /* + * Perform an HTTP request for the given request against the server under test and returns the full HTTP response + * @param request The {@link HttpRequest} to execute + * @param bodyType The body type + * @param The request body type + * @param The response body type + * @return The full {@link HttpResponse} object + * @throws HttpClientResponseException when an error status is returned + */ + HttpResponse exchange(HttpRequest request, Argument bodyType); + + @NonNull + default Optional getPort() { + return Optional.empty(); + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/ServerUnderTestProvider.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/ServerUnderTestProvider.java new file mode 100644 index 00000000000..2ea500fb918 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/ServerUnderTestProvider.java @@ -0,0 +1,66 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.NonNull; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Provides a server to test. + * @author Sergio del Amo + * @since 3.8.0 + */ +@Experimental +@FunctionalInterface +public interface ServerUnderTestProvider { + + /** + * + * @param properties Properties supplied to application context started. + * @return The server under test. + */ + @NonNull + ServerUnderTest getServer(Map properties); + + /** + * + * @param specName value of {@literal spec.name} property used to avoid bean pollution. + * @param properties Properties supplied to application context started. + * @return Server under test + */ + @NonNull + default ServerUnderTest getServer(String specName, Map properties) { + Map props = properties != null ? new HashMap<>(properties) : new HashMap<>(); + if (specName != null) { + props.put("spec.name", specName); + } + return getServer(props); + } + + /** + * + * @param specName value of {@literal spec.name} property used to avoid bean pollution. + * @return Server under test + */ + @NonNull + default ServerUnderTest getServer(String specName) { + return getServer(specName, Collections.emptyMap()); + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/ServerUnderTestProviderUtils.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/ServerUnderTestProviderUtils.java new file mode 100644 index 00000000000..ff9bdede0a8 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/ServerUnderTestProviderUtils.java @@ -0,0 +1,49 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck; + +import io.micronaut.context.exceptions.ConfigurationException; +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.NonNull; + +import java.util.Iterator; +import java.util.ServiceLoader; + +/** + * Utility class to retrieve a {@link ServerUnderTestProvider} via a Service Loader. + * @author Sergio del Amo + * @since 3.8.0 + */ +@Experimental +public final class ServerUnderTestProviderUtils { + + private ServerUnderTestProviderUtils() { + } + + /** + * + * @return The first {@link ServerUnderTestProvider} loaded via a Service loader. + * @throws ConfigurationException if it cannot load any {@link ServerUnderTestProvider}. + */ + @NonNull + public static ServerUnderTestProvider getServerUnderTestProvider() { + Iterator it = ServiceLoader.load(ServerUnderTestProvider.class).iterator(); + if (it.hasNext()) { + return it.next(); + } + throw new ConfigurationException("No ServiceUnderTestProvider present"); + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/TestScenario.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/TestScenario.java new file mode 100644 index 00000000000..dc2d4ccafe6 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/TestScenario.java @@ -0,0 +1,172 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.http.HttpRequest; +import java.io.IOException; +import java.util.Map; +import java.util.Objects; +import java.util.function.BiConsumer; + +/** + * Defines a HTTP Server Test Scenario. + * @author Sergio del Amo + * @since 3.8.0 + */ +@Experimental +public final class TestScenario { + private final String specName; + private final Map configuration; + + private final HttpRequest request; + private final BiConsumer> assertion; + + private TestScenario(String specName, + Map configuration, + HttpRequest request, + BiConsumer> assertion) { + this.specName = specName; + this.configuration = configuration; + this.request = request; + this.assertion = assertion; + } + + /** + * + * @param specName Value for {@literal spec.name} property. Used to avoid bean pollution. + * @param configuration Test Scenario configuration + * @param request HTTP Request to be sent in the test scenario + * @param assertion Assertion for a request and server. + * @throws IOException Exception thrown while getting the server under test. + */ + public static void asserts(String specName, + Map configuration, + HttpRequest request, + BiConsumer> assertion) throws IOException { + TestScenario.builder() + .specName(specName) + .configuration(configuration) + .request(request) + .assertion(assertion) + .run(); + } + + /** + * + * @param specName Value for {@literal spec.name} property. Used to avoid bean pollution. + * @param request HTTP Request to be sent in the test scenario + * @param assertion Assertion for a request and server. + * @throws IOException Exception thrown while getting the server under test. + */ + public static void asserts(String specName, + HttpRequest request, + BiConsumer> assertion) throws IOException { + TestScenario.builder() + .specName(specName) + .request(request) + .assertion(assertion) + .run(); + } + + /** + * + * @return A Test Scenario builder. + */ + public static TestScenario.Builder builder() { + return new Builder(); + } + + private void run() throws IOException { + try (ServerUnderTest server = ServerUnderTestProviderUtils.getServerUnderTestProvider().getServer(specName, configuration)) { + if (assertion != null) { + assertion.accept(server, request); + } + } + } + + /** + * Test Scenario Builder. + */ + public static class Builder { + + private Map configuration; + + private String specName; + + private BiConsumer> assertion; + + private HttpRequest request; + + /** + * + * @param specName Value for {@literal spec.name} property. Used to avoid bean pollution. + * @return Test Scenario builder + */ + public Builder specName(String specName) { + this.specName = specName; + return this; + } + + /** + * + * @param request HTTP Request to be sent in the test scenario + * @return The Test Scneario Builder + */ + public Builder request(HttpRequest request) { + this.request = request; + return this; + } + + /** + * + * @param configuration Test Scenario configuration + * @return Test scenario builder + */ + public Builder configuration(Map configuration) { + this.configuration = configuration; + return this; + } + + /** + * + * @param assertion Assertion for a request and server. + * @return The Test Scenario Builder + */ + public Builder assertion(BiConsumer> assertion) { + this.assertion = assertion; + return this; + } + + /** + * + * @return Builds a Test scenario + */ + private TestScenario build() { + return new TestScenario(specName, configuration, + Objects.requireNonNull(request), + Objects.requireNonNull(assertion)); + } + + /** + * Runs the Test Scneario. + * @throws IOException Exception thrown while getting the server under test. + */ + public void run() throws IOException { + build().run(); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/BodyArgumentTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/BodyArgumentTest.java new file mode 100644 index 00000000000..1923451f202 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/BodyArgumentTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import org.junit.jupiter.api.Test; +import java.io.IOException; +import static io.micronaut.http.server.tck.TestScenario.asserts; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class BodyArgumentTest { + public static final String SPEC_NAME = "BodyArgumentTest"; + + /** + * @see micronaut-aws #1164 + */ + @Test + void testBodyArguments() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/body-arguments-test/getA", "{\"a\":\"A\",\"b\":\"B\"}").header(HttpHeaders.ACCEPT, MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("A") + .build())); + } + + @Controller("/body-arguments-test") + @Requires(property = "spec.name", value = SPEC_NAME) + static class BodyController { + + @Post(uri = "/getA") + @Produces(MediaType.TEXT_PLAIN) + String getA(String a) { + return a; + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/BodyTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/BodyTest.java new file mode 100644 index 00000000000..27c28cde8cf --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/BodyTest.java @@ -0,0 +1,166 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.async.annotation.SingleResult; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Status; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; + +import java.io.IOException; +import java.util.Objects; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class BodyTest { + public static final String SPEC_NAME = "BodyTest"; + + @Test + void testCustomBodyPOJO() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/response-body/pojo", "{\"x\":10,\"y\":20}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, + HttpResponseAssertion.builder() + .status(HttpStatus.CREATED) + .body("{\"x\":10,\"y\":20}") + .build())); + } + + @Test + void testCustomBodyPOJODefaultToJSON() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/response-body/pojo", "{\"x\":10,\"y\":20}"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, + HttpResponseAssertion.builder() + .status(HttpStatus.CREATED) + .body("{\"x\":10,\"y\":20}") + .build())); + } + + @Test + void testCustomBodyPOJOWithWholeRequest() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/response-body/pojo-and-request", "{\"x\":10,\"y\":20}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> HttpResponseAssertion.builder() + .status(HttpStatus.CREATED) + .body("{\"x\":10,\"y\":20}") + .build()); + } + + @Test + void testCustomBodyPOJOReactiveTypes() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/response-body/pojo-reactive", "{\"x\":10,\"y\":20}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, + HttpResponseAssertion.builder() + .status(HttpStatus.CREATED) + .body("{\"x\":10,\"y\":20}") + .build())); + } + + @Controller("/response-body") + @Requires(property = "spec.name", value = SPEC_NAME) + static class BodyController { + + @Post(uri = "/pojo") + @Status(HttpStatus.CREATED) + Point post(@Body Point data) { + return data; + } + + @Post(uri = "/pojo-and-request") + @Status(HttpStatus.CREATED) + Point postRequest(HttpRequest request) { + return request.getBody().orElse(null); + } + + @Post(uri = "/pojo-reactive") + @Status(HttpStatus.CREATED) + @SingleResult + Publisher post(@Body Publisher data) { + return data; + } + + @Post(uri = "/bytes", consumes = MediaType.TEXT_PLAIN) + @Status(HttpStatus.CREATED) + String postBytes(@Body byte[] bytes) { + return new String(bytes); + } + } + + static class Point { + private Integer x; + private Integer y; + + public Integer getX() { + return x; + } + + public void setX(Integer x) { + this.x = x; + } + + public Integer getY() { + return y; + } + + public void setY(Integer y) { + this.y = y; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + + Point point = (Point) o; + + if (!Objects.equals(x, point.x)) { + return false; + } + return Objects.equals(y, point.y); + } + + @Override + public int hashCode() { + int result = x != null ? x.hashCode() : 0; + result = 31 * result + (y != null ? y.hashCode() : 0); + return result; + } + } + +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ConsumesTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ConsumesTest.java new file mode 100644 index 00000000000..b3218f5c305 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ConsumesTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Consumes; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import org.junit.jupiter.api.Test; +import java.io.IOException; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class ConsumesTest { + public static final String SPEC_NAME = "ConsumesTest"; + + @Test + void testMultipleConsumesDefinition() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/consumes-test", "{\"name\":\"Fred\"}").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"name\":\"Fred\"}") + .build())); + } + + @Controller("/consumes-test") + @Requires(property = "spec.name", value = SPEC_NAME) + static class ConsumesController { + + @Post("/") + @Consumes({MediaType.APPLICATION_FORM_URLENCODED, MediaType.APPLICATION_JSON}) + Pojo save(@Body Pojo pojo) { + return pojo; + } + } + + static class Pojo { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/CookiesTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/CookiesTest.java new file mode 100644 index 00000000000..c11013a46d5 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/CookiesTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.CookieValue; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.cookie.Cookie; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class CookiesTest { + public static final String SPEC_NAME = "CookiesTest"; + + @Test + void testCookieBind() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/cookies-test/bind") + .cookie(Cookie.of("one", "foo")) + .cookie(Cookie.of("two", "bar")), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"one\":\"foo\",\"two\":\"bar\"}") + .build())); + } + + @Test + void testGetCookiesMethod() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/cookies-test/all") + .cookie(Cookie.of("one", "foo")) + .cookie(Cookie.of("two", "bar")), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"one\":\"foo\",\"two\":\"bar\"}") + .build())); + } + + @Test + void testNoCookie() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/cookies-test/all"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{}") + .build())); + } + + @Controller("/cookies-test") + @Requires(property = "spec.name", value = SPEC_NAME) + static class CookieController { + + @Get(uri = "/all") + Map all(HttpRequest request) { + Map map = new HashMap<>(); + for (String cookieName : request.getCookies().names()) { + map.put(cookieName, request.getCookies().get(cookieName).getValue()); + } + return map; + } + + @Get(uri = "/bind") + Map all(@CookieValue String one, @CookieValue String two) { + return CollectionUtils.mapOf( + "one", one, + "two", two + ); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/DeleteWithoutBodyTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/DeleteWithoutBodyTest.java new file mode 100644 index 00000000000..c48393b448e --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/DeleteWithoutBodyTest.java @@ -0,0 +1,64 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpHeaderValues; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Delete; +import io.micronaut.http.annotation.Header; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.http.annotation.Status; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class DeleteWithoutBodyTest { + public static final String SPEC_NAME = "DeleteWithoutBodyTest"; + + @Test + void verifiesItIsPossibleToExposesADeleteEndpointWhichIsInvokedWithoutABody() throws IOException { + asserts(SPEC_NAME, + HttpRequest.DELETE("/sessions/sergio").header(HttpHeaders.AUTHORIZATION, HttpHeaderValues.AUTHORIZATION_PREFIX_BEARER + " xxx"), + (server, request) -> { + HttpResponse response = assertDoesNotThrow(() -> server.exchange(request)); + assertEquals(HttpStatus.OK, response.getStatus()); + }); + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Controller("/sessions") + static class SessionsController { + @Status(HttpStatus.OK) + @Delete("/{username}") + void delete(@PathVariable String username, + @Header(HttpHeaders.AUTHORIZATION) String authorization) { + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ErrorHandlerTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ErrorHandlerTest.java new file mode 100644 index 00000000000..05d55dded1c --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ErrorHandlerTest.java @@ -0,0 +1,362 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Error; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.annotation.Status; +import io.micronaut.http.codec.CodecException; +import io.micronaut.http.hateoas.JsonError; +import io.micronaut.http.hateoas.Link; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import io.micronaut.http.server.tck.ServerUnderTest; +import io.micronaut.http.server.tck.ServerUnderTestProviderUtils; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; + +import javax.validation.Valid; +import javax.validation.constraints.Min; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) + +public class ErrorHandlerTest { + public static final String SPEC_NAME = "ErrorHandlerTest"; + public static final String PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS = "micronaut.server.cors.configurations.web.allowed-origins"; + public static final String PROPERTY_MICRONAUT_SERVER_CORS_ENABLED = "micronaut.server.cors.enabled"; + public static final String LOCALHOST = "http://localhost:8080"; + + @Test + void testCustomGlobalExceptionHandlersDeclaredInController() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS, Collections.singletonList("http://localhost:8080"), + PROPERTY_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ), + HttpRequest.GET("/errors/global-ctrl").header(HttpHeaders.CONTENT_TYPE, io.micronaut.http.MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, + HttpStatus.OK, + "bad things happens globally", + Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN))); + } + + @Test + void testCustomGlobalExceptionHandlers() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS, Collections.singletonList("http://localhost:8080"), + PROPERTY_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ), HttpRequest.GET("/errors/global") + .header(HttpHeaders.CONTENT_TYPE, io.micronaut.http.MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, + HttpStatus.OK, + "Exception Handled", + Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN))); + } + + @Test + void testCustomGlobalExceptionHandlersForPOSTWithBody() throws IOException { + Map configuration = CollectionUtils.mapOf( + PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS, Collections.singletonList("http://localhost:8080"), + PROPERTY_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ); + try (ServerUnderTest server = ServerUnderTestProviderUtils.getServerUnderTestProvider().getServer(SPEC_NAME, configuration)) { + ObjectMapper objectMapper = server.getApplicationContext().getBean(ObjectMapper.class); + HttpRequest request = HttpRequest.POST("/json/errors/global", objectMapper.writeValueAsString(new RequestObject(101))) + .header(HttpHeaders.CONTENT_TYPE, io.micronaut.http.MediaType.APPLICATION_JSON); + AssertionUtils.assertDoesNotThrow(server, request, + HttpStatus.OK, + "{\"message\":\"Error: bad things when post and body in request\",\"", + Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)); + } + } + + @Test + void testCustomGlobalStatusHandlersDeclaredInController() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS, Collections.singletonList("http://localhost:8080"), + PROPERTY_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ), HttpRequest.GET("/errors/global-status-ctrl"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, + HttpStatus.OK, + "global status", + Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN))); + } + + @Test + void testLocalExceptionHandlers() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS, Collections.singletonList("http://localhost:8080"), + PROPERTY_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE), + HttpRequest.GET("/errors/local"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, + HttpStatus.OK, + "bad things", + Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN))); + } + + @Test + void jsonMessageFormatErrorsReturn400() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS, Collections.singletonList("http://localhost:8080"), + PROPERTY_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE), + HttpRequest.POST("/json/jsonBody", "{\"numberField\": \"textInsteadOfNumber\"}"), + (server, request) -> AssertionUtils.assertThrows(server, request, + HttpResponseAssertion.builder() + .status(HttpStatus.BAD_REQUEST) + .headers(Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)) + .build() + )); + } + + @Test + void corsHeadersArePresentAfterFailedDeserialisationWhenErrorHandlerIsUsed() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS, Collections.singletonList("http://localhost:8080"), + PROPERTY_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ), HttpRequest.POST("/json/errors/global", "{\"numberField\": \"string is not a number\"}") + .header(HttpHeaders.ORIGIN, LOCALHOST), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .headers(Collections.singletonMap(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, LOCALHOST)) + .build())); + } + + @Test + void corsHeadersArePresentAfterFailedDeserialisation() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS, Collections.singletonList(LOCALHOST), + PROPERTY_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ), HttpRequest.POST("/json/jsonBody", "{\"numberField\": \"string is not a number\"}") + .header(HttpHeaders.ORIGIN, LOCALHOST), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.BAD_REQUEST) + .headers(Collections.singletonMap(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, LOCALHOST)) + .build())); + } + + @Test + void corsHeadersArePresentAfterExceptions() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS, Collections.singletonList(LOCALHOST), + PROPERTY_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ), HttpRequest.GET("/errors/global").header(HttpHeaders.ORIGIN, LOCALHOST), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .headers(Collections.singletonMap(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, LOCALHOST)) + .build())); + } + + @Test + void messageValidationErrorsReturn400() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + PROPERTY_MICRONAUT_SERVER_CORS_CONFIGURATIONS_WEB_ALLOWED_ORIGINS, Collections.singletonList("http://localhost:8080"), + PROPERTY_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ), HttpRequest.POST("/json/jsonBody", "{\"numberField\": 0}"), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.BAD_REQUEST) + .headers(Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)) + .build())); + } + + @Controller("/secret") + @Requires(property = "spec.name", value = SPEC_NAME) + static class SecretController { + @Get + @Produces(MediaType.TEXT_PLAIN) + String index() { + return "area 51 hosts an alien"; + } + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Controller("/errors") + static class ErrorController { + + @Get("/global") + String globalHandler() { + throw new MyException("bad things"); + } + + @Get("/global-ctrl") + String globalControllerHandler() throws GloballyHandledException { + throw new GloballyHandledException("bad things happens globally"); + } + + @Get("/global-status-ctrl") + @Status(HttpStatus.I_AM_A_TEAPOT) + String globalControllerHandlerForStatus() { + return "original global status"; + } + + @Get("/local") + String localHandler() { + throw new AnotherException("bad things"); + } + + @Error + @Produces(io.micronaut.http.MediaType.TEXT_PLAIN) + @Status(HttpStatus.OK) + String localHandler(AnotherException throwable) { + return throwable.getMessage(); + } + } + + @Controller(value = "/json/errors", produces = io.micronaut.http.MediaType.APPLICATION_JSON) + @Requires(property = "spec.name", value = SPEC_NAME) + static class JsonErrorController { + + @Post("/global") + String globalHandlerPost(@Body RequestObject object) { + throw new RuntimeException("bad things when post and body in request"); + } + + @Error + HttpResponse errorHandler(HttpRequest request, RuntimeException exception) { + JsonError error = new JsonError("Error: " + exception.getMessage()) + .link(Link.SELF, Link.of(request.getUri())); + + return HttpResponse.status(HttpStatus.OK) + .body(error); + } + } + + @Introspected + static class RequestObject { + @Min(1L) + private Integer numberField; + + public RequestObject(Integer numberField) { + this.numberField = numberField; + } + + public Integer getNumberField() { + return numberField; + } + } + + @Controller("/json") + @Requires(property = "spec.name", value = SPEC_NAME) + static class JsonController { + @Post("/jsonBody") + String jsonBody(@Valid @Body RequestObject data) { + return "blah"; + } + } + + @Controller("/global-errors") + @Requires(property = "spec.name", value = SPEC_NAME) + static class GlobalErrorController { + + @Error(global = true, exception = GloballyHandledException.class) + @Produces(io.micronaut.http.MediaType.TEXT_PLAIN) + @Status(HttpStatus.OK) + String globallyHandledException(GloballyHandledException throwable) { + return throwable.getMessage(); + } + + @Error(global = true, status = HttpStatus.I_AM_A_TEAPOT) + @Produces(io.micronaut.http.MediaType.TEXT_PLAIN) + @Status(HttpStatus.OK) + String globalControllerHandlerForStatus() { + return "global status"; + } + + } + + @Singleton + @Requires(property = "spec.name", value = SPEC_NAME) + static class CodecExceptionExceptionHandler + implements ExceptionHandler { + + @Override + public HttpResponse handle(HttpRequest request, CodecException exception) { + return HttpResponse.badRequest("Invalid JSON: " + exception.getMessage()).contentType(MediaType.APPLICATION_JSON); + } + } + + @Singleton + @Requires(property = "spec.name", value = SPEC_NAME) + static class RuntimeErrorHandler implements ExceptionHandler { + + @Override + public HttpResponse handle(HttpRequest request, RuntimeException exception) { + return HttpResponse.serverError("Exception: " + exception.getMessage()) + .contentType(MediaType.TEXT_PLAIN); + } + } + + @Singleton + @Requires(property = "spec.name", value = SPEC_NAME) + static class MyErrorHandler implements ExceptionHandler { + + @Override + public HttpResponse handle(HttpRequest request, MyException exception) { + return HttpResponse.ok("Exception Handled") + .contentType(MediaType.TEXT_PLAIN); + } + } + + + static class MyException extends RuntimeException { + public MyException(String badThings) { + super(badThings); + } + } + + static class AnotherException extends RuntimeException { + public AnotherException(String badThings) { + super(badThings); + } + } + + static class GloballyHandledException extends Exception { + public GloballyHandledException(String badThingsHappensGlobally) { + super(badThingsHappensGlobally); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/FilterErrorTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/FilterErrorTest.java new file mode 100644 index 00000000000..a4ba93082eb --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/FilterErrorTest.java @@ -0,0 +1,340 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.condition.Condition; +import io.micronaut.context.condition.ConditionContext; +import io.micronaut.core.async.publisher.Publishers; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpAttributes; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Error; +import io.micronaut.http.annotation.Filter; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Status; +import io.micronaut.http.filter.HttpServerFilter; +import io.micronaut.http.filter.ServerFilterChain; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import io.micronaut.web.router.MethodBasedRouteMatch; +import io.micronaut.web.router.RouteMatch; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class FilterErrorTest { + public static final String SPEC_NAME = "FilterErrorTest"; + + @Test + void testFilterThrowingExceptionHandledByExceptionHandlerThrowingException() throws IOException { + asserts(SPEC_NAME + "3", + HttpRequest.GET("/filter-error-spec-3") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> { + AssertionUtils.assertThrows(server, request, + HttpResponseAssertion.builder() + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("from exception handler") + .build()); + ExceptionException filter = server.getApplicationContext().getBean(ExceptionException.class); + assertEquals(1, filter.executedCount.get()); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, filter.responseStatus.getAndSet(null)); + }); + } + + @Test + void testTheErrorRouteIsTheRouteMatch() throws IOException { + asserts(SPEC_NAME + "4", + HttpRequest.GET("/filter-error-spec-4/status").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> { + AssertionUtils.assertDoesNotThrow(server, request, + HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .build()); + ExceptionRoute filter = server.getApplicationContext().getBean(ExceptionRoute.class); + RouteMatch match = filter.routeMatch.getAndSet(null); + assertTrue(match instanceof MethodBasedRouteMatch); + assertEquals("testStatus", ((MethodBasedRouteMatch) match).getName()); + }); + } + + @Test + void testNonOncePerRequestFilterThrowingErrorDoesNotLoop() throws IOException { + asserts(SPEC_NAME + "2", + HttpRequest.GET("/filter-error-spec").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> { + AssertionUtils.assertThrows(server, request, + HttpResponseAssertion.builder() + .status(HttpStatus.BAD_REQUEST) + .body("from filter exception handler") + .build()); + FirstEvery filter = server.getApplicationContext().getBean(FirstEvery.class); + assertEquals(1, filter.executedCount.get()); + }); + } + + @Test + void testErrorsEmittedFromSecondFilterInteractingWithExceptionHandlers() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/filter-error-spec").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON).header("X-Passthru", StringUtils.TRUE), + (server, request) -> { + AssertionUtils.assertThrows(server, request, + HttpResponseAssertion.builder() + .status(HttpStatus.BAD_REQUEST) + .body("from NEXT filter exception handle").build()); + + First first = server.getApplicationContext().getBean(First.class); + Next next = server.getApplicationContext().getBean(Next.class); + + assertEquals(1, first.executedCount.get()); + assertEquals(HttpStatus.BAD_REQUEST, first.responseStatus.getAndSet(null)); + assertEquals(1, next.executedCount.get()); + }); + } + + @Test + void testErrorsEmittedFromFiltersInteractingWithExceptionHandlers() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/filter-error-spec").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> { + AssertionUtils.assertThrows(server, request, + HttpResponseAssertion.builder() + .status(HttpStatus.BAD_REQUEST) + .body("from filter exception handler").build()); + + First first = server.getApplicationContext().getBean(First.class); + Next next = server.getApplicationContext().getBean(Next.class); + + assertEquals(1, first.executedCount.get()); + assertNull(first.responseStatus.getAndSet(null)); + assertEquals(0, next.executedCount.get()); + }); + } + + static class FilterExceptionException extends RuntimeException { + } + + static class FilterException extends RuntimeException { + } + + static class NextFilterException extends RuntimeException { + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Filter(Filter.MATCH_ALL_PATTERN) + static class First implements HttpServerFilter { + AtomicInteger executedCount = new AtomicInteger(0); + AtomicReference responseStatus = new AtomicReference<>(); + + private void setResponse(MutableHttpResponse r) { + responseStatus.set(r.status()); + } + + @Override + public Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + executedCount.incrementAndGet(); + if (StringUtils.isTrue(request.getHeaders().get("X-Passthru"))) { + return Publishers.then(chain.proceed(request), this::setResponse); + } + return Publishers.just(new FilterException()); + } + + @Override + public int getOrder() { + return 10; + } + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Filter(Filter.MATCH_ALL_PATTERN) + static class Next implements HttpServerFilter { + AtomicInteger executedCount = new AtomicInteger(0); + + @Override + public Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + executedCount.incrementAndGet(); + return Publishers.just(new NextFilterException()); + } + + @Override + public int getOrder() { + return 20; + } + } + + @Requires(property = "spec.name", value = SPEC_NAME + "2") + @Filter(Filter.MATCH_ALL_PATTERN) + static class FirstEvery implements HttpServerFilter { + AtomicInteger executedCount = new AtomicInteger(0); + + @Override + public Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + executedCount.incrementAndGet(); + return Publishers.just(new FilterException()); + } + + @Override + public int getOrder() { + return 10; + } + } + + @Requires(property = "spec.name", value = SPEC_NAME + "3") + @Filter(Filter.MATCH_ALL_PATTERN) + static class ExceptionException implements HttpServerFilter { + AtomicInteger executedCount = new AtomicInteger(0); + AtomicReference responseStatus = new AtomicReference<>(); + + private void setResponse(MutableHttpResponse r) { + responseStatus.set(r.status()); + } + + @Override + public Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + executedCount.incrementAndGet(); + return Publishers.then(chain.proceed(request), + this::setResponse); + } + + @Override + public int getOrder() { + return 10; + } + } + + @Requires(property = "spec.name", value = SPEC_NAME + "4") + @Filter(Filter.MATCH_ALL_PATTERN) + static class ExceptionRoute implements HttpServerFilter { + AtomicReference> routeMatch = new AtomicReference<>(); + + @Override + public Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + return Publishers.then(chain.proceed(request), + httpResponse -> routeMatch.set(httpResponse.getAttribute(HttpAttributes.ROUTE_MATCH, RouteMatch.class).get())); + } + + @Override + public int getOrder() { + return 10; + } + } + + @Requires(condition = FilterCondition.class) + @Controller("/filter-error-spec") + static class NeverReachedController { + @Get + String get() { + return "OK"; + } + } + + @Requires(condition = FilterCondition.class) + @Controller("/filter-error-spec-3") + static class HandledByHandlerController { + @Get + String get() { + throw new FilterExceptionException(); + } + } + + @Requires(condition = FilterCondition.class) + @Controller("/filter-error-spec-4") + static class HandledByErrorRouteController { + @Get("/exception") + String getException() { + throw new FilterExceptionException(); + } + + @Get("/status") + HttpStatus getStatus() { + return HttpStatus.NOT_FOUND; + } + + @Error(exception = FilterExceptionException.class) + @Status(HttpStatus.OK) + void testException() { + + } + + @Error(status = HttpStatus.NOT_FOUND) + @Status(HttpStatus.OK) + void testStatus() { + + } + } + + static class FilterCondition implements Condition { + + @Override + public boolean matches(ConditionContext context) { + return context.getProperty("spec.name", String.class) + .map(val -> val.equals(SPEC_NAME + "4") || val.equals(SPEC_NAME + "3") || val.equals(SPEC_NAME + "2") || val.equals(SPEC_NAME)) + .orElse(false); + } + } + + @Requires(condition = FilterCondition.class) + @Singleton + static class FilterExceptionExceptionHandler implements ExceptionHandler> { + + @Override + public HttpResponse handle(HttpRequest request, FilterExceptionException exception) { + throw new RuntimeException("from exception handler"); + } + } + + @Requires(condition = FilterCondition.class) + @Singleton + static class FilterExceptionHandler implements ExceptionHandler> { + + @Override + public HttpResponse handle(HttpRequest request, FilterException exception) { + return HttpResponse.badRequest("from filter exception handler"); + } + } + + @Requires(condition = FilterCondition.class) + @Singleton + static class NextFilterExceptionHandler implements ExceptionHandler> { + + @Override + public HttpResponse handle(HttpRequest request, NextFilterException exception) { + return HttpResponse.badRequest("from NEXT filter exception handler"); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/FiltersTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/FiltersTest.java new file mode 100644 index 00000000000..64a52d674bf --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/FiltersTest.java @@ -0,0 +1,151 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.async.publisher.Publishers; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.core.util.StringUtils; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Filter; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.filter.HttpServerFilter; +import io.micronaut.http.filter.ServerFilterChain; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import io.micronaut.http.server.tck.ServerUnderTest; +import io.micronaut.http.server.tck.ServerUnderTestProviderUtils; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; + +import java.io.IOException; +import java.util.Collections; +import java.util.Map; +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class FiltersTest { + public static final String SPEC_NAME = "FiltersTest"; + public static final String PROP_MICRONAUT_SERVER_CORS_ENABLED = "micronaut.server.cors.enabled"; + + @Test + void testFiltersAreRunCorrectly() throws IOException { + Map configuration = CollectionUtils.mapOf( + PROP_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ); + try (ServerUnderTest server = ServerUnderTestProviderUtils.getServerUnderTestProvider().getServer(SPEC_NAME, configuration)) { + HttpRequest request = HttpRequest.GET("/filter-test/ok"); + AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("OK") + .headers(Collections.singletonMap("X-Test-Filter", StringUtils.TRUE)) + .build()); + } + } + + @Test + void filtersAreAppliedOnNonMatchingMethodsCorsFilterWorks() throws IOException { + Map configuration = CollectionUtils.mapOf( + PROP_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ); + try (ServerUnderTest server = ServerUnderTestProviderUtils.getServerUnderTestProvider().getServer(SPEC_NAME, configuration)) { + HttpRequest request = HttpRequest.OPTIONS("/filter-test/ok").header("Origin", "https://micronaut.io") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"); + AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .headers(Collections.singletonMap(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, "https://micronaut.io")) + .build()); + } + } + + @Test + void filtersAreAppliedOnNonMatchingMethodsCorsFilterDisableIfNotPreflight() throws IOException { + Map configuration = CollectionUtils.mapOf( + PROP_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ); + try (ServerUnderTest server = ServerUnderTestProviderUtils.getServerUnderTestProvider().getServer(SPEC_NAME, configuration)) { + HttpRequest request = HttpRequest.OPTIONS("/filter-test/ok"); + AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.METHOD_NOT_ALLOWED) + .build()); + } + } + + @Test + void testFiltersAreRunCorrectlyWithCustomExceptionHandler() throws IOException { + Map configuration = CollectionUtils.mapOf( + PROP_MICRONAUT_SERVER_CORS_ENABLED, StringUtils.TRUE + ); + try (ServerUnderTest server = ServerUnderTestProviderUtils.getServerUnderTestProvider().getServer(SPEC_NAME, configuration)) { + HttpRequest request = HttpRequest.GET("/filter-test/exception"); + AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("Exception Handled") + .headers(Collections.singletonMap("X-Test-Filter", StringUtils.TRUE)) + .build()); + } + } + + @Controller("/filter-test") + @Requires(property = "spec.name", value = SPEC_NAME) + static class TestController { + @Get("/ok") + String ok() { + return "OK"; + } + + @Get("/exception") + void exception() { + throw new CustomException(); + } + } + + @Filter("/filter-test/**") + @Requires(property = "spec.name", value = SPEC_NAME) + static class TestFilter implements HttpServerFilter { + @Override + public Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + return Publishers.map(chain.proceed(request), httpResponse -> { + httpResponse.getHeaders().add("X-Test-Filter", "true"); + return httpResponse; + }); + } + } + + static class CustomException extends RuntimeException { + } + + @Produces + @Singleton + @Requires(property = "spec.name", value = SPEC_NAME) + static class CustomExceptionHandler implements ExceptionHandler> { + @Override + public HttpResponse handle(HttpRequest request, CustomException exception) { + return HttpResponse.ok("Exception Handled"); + } + + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/FluxTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/FluxTest.java new file mode 100644 index 00000000000..887caeebcb2 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/FluxTest.java @@ -0,0 +1,60 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class FluxTest { + public static final String SPEC_NAME = "FluxTest"; + + @Test + void testControllerReturningAFlux() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/users"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("[{\"name\":\"Joe\"},{\"name\":\"Lewis\"}]") + .build())); + } + + @Controller("/users") + @Requires(property = "spec.name", value = SPEC_NAME) + static class UserController { + @Get + Flux> getAll() { + return Flux.fromIterable(Arrays.asList(Collections.singletonMap("name", "Joe"), Collections.singletonMap("name", "Lewis"))); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/HelloWorldTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/HelloWorldTest.java new file mode 100644 index 00000000000..86089cb5d28 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/HelloWorldTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.tck.AssertionUtils; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import io.micronaut.http.uri.UriBuilder; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Collections; + + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class HelloWorldTest { + public static final String SPEC_NAME = "HelloWorldTest"; + + @Test + void helloWorld() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET(UriBuilder.of("/hello").path("world").build()).accept(MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, + HttpStatus.OK, + "Hello World", + Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN))); + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Controller("/hello") + static class HelloWorldController { + @Produces(MediaType.TEXT_PLAIN) + @Get("/world") + String hello() { + return "Hello World"; + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/MiscTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/MiscTest.java new file mode 100644 index 00000000000..5e665b41c0a --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/MiscTest.java @@ -0,0 +1,266 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Consumes; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import org.junit.jupiter.api.Test; + +import javax.validation.constraints.NotBlank; +import java.io.IOException; +import java.util.Collections; +import java.util.Map; + + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class MiscTest { + public static final String SPEC_NAME = "MiscTest"; + + /** + * + * @see micronaut-aws #868 + */ + @Test + void testSelectedRouteReflectsAcceptHeader() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/bar/ok").header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"status\":\"ok\"}") + .build())); + + asserts(SPEC_NAME, + HttpRequest.GET("/bar/ok").header(HttpHeaders.ACCEPT, MediaType.TEXT_HTML), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, + HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("
ok
") + .build())); + } + + @Test + void testBehaviourOf404() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/does-not-exist").header("Accept", MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.NOT_FOUND) + .build())); + } + + @Test + void postFormUrlEncodedBodyBindingToPojoWorks() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/form", "message=World").header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"message\":\"Hello World\"}") + .build())); + } + + @Test + void postFormUrlEncodedBodyBindingToPojoWorksIfYouDontSpecifyBodyAnnotation() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/form/without-body-annotation", "message=World") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"message\":\"Hello World\"}") + .build())); + } + + @Test + void formUrlEncodedWithBodyAnnotationAndANestedAttribute() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/form/nested-attribute", "message=World") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"message\":\"Hello World\"}") + .build())); + } + + /** + * + * @see micronaut-aws #1410 + */ + @Test + void applicationJsonWithBodyAnnotationAndANestedAttribute() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/form/json-nested-attribute", "{\"message\":\"World\"}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"message\":\"Hello World\"}") + .build())); + } + + @Test + void applicationJsonWithoutBodyAnnotation() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/form/json-without-body-annotation", "{\"message\":\"World\"}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"message\":\"Hello World\"}") + .build())); + } + + @Test + void applicationJsonWithBodyAnnotationAndANestedAttributeAndMapReturnRenderedAsJSON() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/form/json-nested-attribute-with-map-return", "{\"message\":\"World\"}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"message\":\"Hello World\"}") + .build())); + } + + @Test + void applicationJsonWithBodyAnnotationAndObjectReturnRenderedAsJson() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/form/json-with-body-annotation-and-with-object-return", "{\"message\":\"World\"}") + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("{\"greeting\":\"Hello World\"}") + .build())); + } + + @Controller + @Requires(property = "spec.name", value = SPEC_NAME) + static class SimpleController { + @Get(uri = "/foo") + HttpResponse getParamValue(HttpRequest request) { + return HttpResponse.ok() + .body(request.getParameters().get("param")) + .header("foo", "bar"); + } + } + + @Controller("/bar") + @Requires(property = "spec.name", value = SPEC_NAME) + static class ProduceController { + @Get(value = "/ok", produces = MediaType.APPLICATION_JSON) + String getOkAsJson() { + return "{\"status\":\"ok\"}"; + } + + @Get(value = "/ok", produces = MediaType.TEXT_HTML) + String getOkAsHtml() { + return "
ok
"; + } + } + + @Introspected + static class MessageCreate { + + @NonNull + @NotBlank + private final String message; + + MessageCreate(@NonNull String message) { + this.message = message; + } + + @NonNull + String getMessage() { + return message; + } + } + + @Introspected + static class MyResponse { + + @NonNull + @NotBlank + private final String greeting; + + public MyResponse(@NonNull String greeting) { + this.greeting = greeting; + } + + @NonNull + public String getGreeting() { + return greeting; + } + } + + @Controller("/form") + @Requires(property = "spec.name", value = SPEC_NAME) + static class FormController { + + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Post("/without-body-annotation") + String withoutBodyAnnotation(MessageCreate messageCreate) { + return "{\"message\":\"Hello " + messageCreate.getMessage() + "\"}"; + } + + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Post + String save(@Body MessageCreate messageCreate) { + return "{\"message\":\"Hello " + messageCreate.getMessage() + "\"}"; + } + + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Post("/nested-attribute") + String save(@Body("message") String value) { + return "{\"message\":\"Hello " + value + "\"}"; + } + + @Consumes(MediaType.APPLICATION_JSON) + @Post("/json-without-body-annotation") + String jsonWithoutBody(MessageCreate messageCreate) { + return "{\"message\":\"Hello " + messageCreate.getMessage() + "\"}"; + } + + @Consumes(MediaType.APPLICATION_JSON) + @Post("/json-nested-attribute") + String jsonNestedAttribute(@Body("message") String value) { + return "{\"message\":\"Hello " + value + "\"}"; + } + + @Consumes(MediaType.APPLICATION_JSON) + @Post("/json-nested-attribute-with-map-return") + Map jsonNestedAttributeWithMapReturn(@Body("message") String value) { + return Collections.singletonMap("message", "Hello " + value); + } + + @Consumes(MediaType.APPLICATION_JSON) + @Post("/json-with-body-annotation-and-with-object-return") + MyResponse jsonNestedAttributeWithObjectReturn(@Body MessageCreate messageCreate) { + return new MyResponse("Hello " + messageCreate.getMessage()); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ParameterTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ParameterTest.java new file mode 100644 index 00000000000..4ad42b4659f --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ParameterTest.java @@ -0,0 +1,62 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import io.micronaut.http.uri.UriBuilder; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; + + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class ParameterTest { + public static final String SPEC_NAME = "ParameterTest"; + + @Test + void testGetAllMethod() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET(UriBuilder.of("/parameters-test").path("all") + .queryParam("test", "one", "two", "three+four") + .build()), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("[\"one\",\"two\",\"three+four\"]") + .build())); + } + + @Controller("/parameters-test") + @Requires(property = "spec.name", value = SPEC_NAME) + static class BodyController { + + @Get(uri = "/all") + List all(HttpRequest request) { + return request.getParameters().getAll("test"); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/RemoteAddressTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/RemoteAddressTest.java new file mode 100644 index 00000000000..49b94b05ba4 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/RemoteAddressTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.async.publisher.Publishers; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MutableHttpResponse; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Filter; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.filter.HttpServerFilter; +import io.micronaut.http.filter.ServerFilterChain; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; + +import java.io.IOException; +import java.util.Collections; + + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class RemoteAddressTest { + public static final String SPEC_NAME = "RemoteAddressTest"; + + @Test + void testRemoteAddressComesFromIdentitySourceIp() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/remoteAddress/fromSourceIp"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .headers(Collections.singletonMap("X-Captured-Remote-Address", "127.0.0.1")) + .build())); + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Controller("/remoteAddress") + static class TestController { + @Get("fromSourceIp") + void sourceIp() { + } + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Filter("/remoteAddress/**") + static class CaptureRemoteAddressFiter implements HttpServerFilter { + @Override + public Publisher> doFilter(HttpRequest request, ServerFilterChain chain) { + return Publishers.map(chain.proceed(request), httpResponse -> { + httpResponse.getHeaders().add("X-Captured-Remote-Address", request.getRemoteAddress().getAddress().getHostAddress()); + return httpResponse; + }); + } + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Produces + @Singleton + static class CustomExceptionHandler implements ExceptionHandler { + @Override + public HttpResponse handle(HttpRequest request, Exception exception) { + return HttpResponse.serverError(exception.toString()); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ResponseStatusTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ResponseStatusTest.java new file mode 100644 index 00000000000..69959731609 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/ResponseStatusTest.java @@ -0,0 +1,125 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpHeaders; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Delete; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Status; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import org.junit.jupiter.api.Test; + +import javax.validation.ConstraintViolationException; +import java.io.IOException; +import java.util.Collections; +import java.util.Optional; + + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class ResponseStatusTest { + public static final String SPEC_NAME = "ResponseStatusTest"; + + @Test + void testConstraintViolationCauses400() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/response-status/constraint-violation", Collections.emptyMap()).header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.BAD_REQUEST) + .build())); + } + + @Test + void testVoidMethodsDoesNotCause404() throws IOException { + asserts(SPEC_NAME, + HttpRequest.DELETE("/response-status/delete-something").header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.NO_CONTENT) + .build())); + } + + @Test + void testNullCauses404() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/response-status/null").header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.NOT_FOUND) + .build())); + } + + @Test + void testOptionalCauses404() throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET("/response-status/optional").header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.NOT_FOUND) + .build())); + } + + @Test + void testCustomResponseStatus() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/response-status", "foo").header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.CREATED) + .body("foo") + .build())); + } + + @Controller("/response-status") + @Requires(property = "spec.name", value = SPEC_NAME) + static class StatusController { + + @Post(uri = "/", processes = MediaType.TEXT_PLAIN) + @Status(HttpStatus.CREATED) + String post(@Body String data) { + return data; + } + + @Get(uri = "/optional", processes = MediaType.TEXT_PLAIN) + Optional optional() { + return Optional.empty(); + } + + @Get(uri = "/null", processes = MediaType.TEXT_PLAIN) + String returnNull() { + return null; + } + + @Post(uri = "/constraint-violation", processes = MediaType.TEXT_PLAIN) + String constraintViolation() { + throw new ConstraintViolationException("Failed", Collections.emptySet()); + } + + @Status(HttpStatus.NO_CONTENT) + @Delete(uri = "/delete-something", processes = MediaType.TEXT_PLAIN) + void deleteSomething() { + // do nothing + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/StatusTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/StatusTest.java new file mode 100644 index 00000000000..4f4d0a0f6a5 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/StatusTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import io.micronaut.http.server.exceptions.response.ErrorContext; +import io.micronaut.http.server.exceptions.response.ErrorResponseProcessor; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import jakarta.inject.Singleton; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class StatusTest { + public static final String SPEC_NAME = "StatusTest"; + + /** + * @see micronaut-aws #1387 + * @param path Request Path + */ + @ParameterizedTest + @ValueSource(strings = {"/http-status", "/http-response-status", "/http-exception"}) + void testControllerReturningHttpStatus(String path) throws IOException { + asserts(SPEC_NAME, + HttpRequest.GET(path), + (server, request) -> AssertionUtils.assertThrows(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.I_AM_A_TEAPOT) + .build())); + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Controller("/http-status") + static class HttpStatusController { + @Get + HttpStatus index() { + return HttpStatus.I_AM_A_TEAPOT; + } + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Controller("/http-response-status") + static class HttpResponseStatusController { + + @Get + HttpResponse index() { + return HttpResponse.status(HttpStatus.I_AM_A_TEAPOT); + } + } + + @Requires(property = "spec.name", value = SPEC_NAME) + @Controller("/http-exception") + static class HttpResponseErrorController { + + @Get + HttpResponse index() { + throw new TeapotException(); + } + } + + static class TeapotException extends RuntimeException { + } + + @Produces + @Singleton + static class TeapotExceptionHandler implements ExceptionHandler> { + private final ErrorResponseProcessor errorResponseProcessor; + + TeapotExceptionHandler(ErrorResponseProcessor errorResponseProcessor) { + this.errorResponseProcessor = errorResponseProcessor; + } + + @Override + public HttpResponse handle(HttpRequest request, TeapotException e) { + return errorResponseProcessor.processResponse(ErrorContext.builder(request) + .cause(e) + .build(), HttpResponse.status(HttpStatus.I_AM_A_TEAPOT)); + } + } +} diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/VersionTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/VersionTest.java new file mode 100644 index 00000000000..7665853c62f --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/VersionTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2017-2022 original authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 io.micronaut.http.server.tck.tests; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.util.CollectionUtils; +import io.micronaut.core.util.StringUtils; +import io.micronaut.core.version.annotation.Version; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.server.tck.AssertionUtils; +import io.micronaut.http.server.tck.HttpResponseAssertion; +import static io.micronaut.http.server.tck.TestScenario.asserts; +import org.junit.jupiter.api.Test; +import java.io.IOException; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class VersionTest { + public static final String SPEC_NAME = "VersionTest"; + + @Test + void testControllerMethodWithVersion2() throws IOException { + asserts(SPEC_NAME, + CollectionUtils.mapOf( + "micronaut.router.versioning.enabled", StringUtils.TRUE, + "micronaut.router.versioning.header.enabled", StringUtils.TRUE + ), HttpRequest.GET("/version/ping").header("X-API-VERSION", "2"), + (server, request) -> AssertionUtils.assertDoesNotThrow(server, request, HttpResponseAssertion.builder() + .status(HttpStatus.OK) + .body("pong v2") + .build())); + } + + @Controller("/version") + @Requires(property = "spec.name", value = SPEC_NAME) + static class ConsumesController { + + @Get("/ping") + String pingV1() { + return "pong v1"; + } + + @Version("2") + @Get("/ping") + String pingV2() { + return "pong v2"; + } + } +} diff --git a/settings.gradle b/settings.gradle index d15dc4124d5..c38cbc4b103 100644 --- a/settings.gradle +++ b/settings.gradle @@ -14,8 +14,10 @@ pluginManagement { } plugins { - id 'io.micronaut.build.shared.settings' version '5.3.14' + id 'io.micronaut.build.shared.settings' version '5.3.15' } +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + rootProject.name = 'micronaut' @@ -35,6 +37,7 @@ include "http-client-core" include "http-client" include "http-netty" include "http-server" +include "http-server-tck" include "http-server-netty" include "http-validation" include "inject" @@ -61,9 +64,11 @@ include "test-suite" include "test-suite-helper" include "test-suite-javax-inject" include "test-suite-jakarta-inject-bean-import" +include "test-suite-http-server-tck-netty" include "test-suite-kotlin" include "test-suite-graal" include "test-suite-groovy" +include "test-suite-groovy" include "test-utils" // benchmarks diff --git a/test-suite-http-server-tck-netty/build.gradle.kts b/test-suite-http-server-tck-netty/build.gradle.kts new file mode 100644 index 00000000000..1e36c4d3594 --- /dev/null +++ b/test-suite-http-server-tck-netty/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("io.micronaut.build.internal.convention-test-library") +} +dependencies { + testImplementation(projects.httpServerNetty) + testImplementation(projects.httpClient) + testImplementation(projects.httpServerTck) + testImplementation(libs.junit.platform.engine) +} diff --git a/test-suite-http-server-tck-netty/src/test/java/io/micronaut/http/server/tck/netty/tests/NettyHttpServerTestSuite.java b/test-suite-http-server-tck-netty/src/test/java/io/micronaut/http/server/tck/netty/tests/NettyHttpServerTestSuite.java new file mode 100644 index 00000000000..74d27fb1ce1 --- /dev/null +++ b/test-suite-http-server-tck-netty/src/test/java/io/micronaut/http/server/tck/netty/tests/NettyHttpServerTestSuite.java @@ -0,0 +1,11 @@ +package io.micronaut.http.server.tck.netty.tests; + +import org.junit.platform.suite.api.SelectPackages; +import org.junit.platform.suite.api.Suite; +import org.junit.platform.suite.api.SuiteDisplayName; + +@Suite +@SelectPackages("io.micronaut.http.server.tck.tests") +@SuiteDisplayName("HTTP Server TCK for Netty") +public class NettyHttpServerTestSuite { +} diff --git a/test-suite-http-server-tck-netty/src/test/resources/META-INF/services/io.micronaut.http.server.tck.ServerUnderTestProvider b/test-suite-http-server-tck-netty/src/test/resources/META-INF/services/io.micronaut.http.server.tck.ServerUnderTestProvider new file mode 100644 index 00000000000..adf15625293 --- /dev/null +++ b/test-suite-http-server-tck-netty/src/test/resources/META-INF/services/io.micronaut.http.server.tck.ServerUnderTestProvider @@ -0,0 +1 @@ +io.micronaut.http.server.tck.EmbeddedServerUnderTestProvider diff --git a/test-suite-http-server-tck-netty/src/test/resources/logback.xml b/test-suite-http-server-tck-netty/src/test/resources/logback.xml new file mode 100644 index 00000000000..8eb8c3a8170 --- /dev/null +++ b/test-suite-http-server-tck-netty/src/test/resources/logback.xml @@ -0,0 +1,10 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + +