diff --git a/function/src/main/java/io/micronaut/function/executor/FunctionInitializer.java b/function/src/main/java/io/micronaut/function/executor/FunctionInitializer.java index 9d30f57e2bf..ff29ad60a0d 100644 --- a/function/src/main/java/io/micronaut/function/executor/FunctionInitializer.java +++ b/function/src/main/java/io/micronaut/function/executor/FunctionInitializer.java @@ -26,7 +26,6 @@ import io.micronaut.http.codec.MediaTypeCodecRegistry; import io.micronaut.inject.annotation.MutableAnnotationMetadata; -import java.io.IOException; import java.util.Collections; import java.util.function.Function; @@ -96,7 +95,6 @@ public void close() { * * @param args The arguments passed to main * @param supplier The function that executes this function - * @throws IOException If an error occurs */ public void run(String[] args, Function supplier) { ApplicationContext applicationContext = this.applicationContext; diff --git a/http-server-netty/build.gradle b/http-server-netty/build.gradle index e4d2a0d221e..d5a7bf97c12 100644 --- a/http-server-netty/build.gradle +++ b/http-server-netty/build.gradle @@ -105,6 +105,11 @@ dependencies { testImplementation project(":websocket") testImplementation(libs.mimepull) + + testImplementation(libs.micronaut.test.junit5) { + exclude group: 'io.micronaut' + } + testImplementation(libs.junit.jupiter.api) } tasks.withType(Test).configureEach { diff --git a/http-server-netty/src/test/java/io/micronaut/http/server/exceptions/response/DefaultHtmlErrorResponseBodyProviderTest.java b/http-server-netty/src/test/java/io/micronaut/http/server/exceptions/response/DefaultHtmlErrorResponseBodyProviderTest.java new file mode 100644 index 00000000000..328d67c794b --- /dev/null +++ b/http-server-netty/src/test/java/io/micronaut/http/server/exceptions/response/DefaultHtmlErrorResponseBodyProviderTest.java @@ -0,0 +1,134 @@ +package io.micronaut.http.server.exceptions.response; + +import io.micronaut.context.MessageSource; +import io.micronaut.context.annotation.Factory; +import io.micronaut.context.annotation.Property; +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.i18n.ResourceBundleMessageSource; +import io.micronaut.core.annotation.Introspected; +import io.micronaut.http.*; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.annotation.Status; +import io.micronaut.http.client.BlockingHttpClient; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import spock.lang.Specification; + +import java.util.Optional; + +import static org.junit.Assert.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Property(name = "spec.name", value = "DefaultHtmlBodyErrorResponseProviderTest") +@MicronautTest +class DefaultHtmlErrorResponseBodyProviderTest extends Specification { + private static final Logger LOG = LoggerFactory.getLogger(DefaultHtmlErrorResponseBodyProviderTest.class); + + @Inject + HtmlErrorResponseBodyProvider htmlProvider; + + @Client("/") + @Inject + HttpClient httpClient; + + @Test + void ifRequestAcceptsBothJsonAnHtmlJsonIsUsed() { + BlockingHttpClient client = httpClient.toBlocking(); + HttpClientResponseException ex = assertThrows(HttpClientResponseException.class, () -> + client.exchange(HttpRequest.POST("/book/save", new Book("Building Microservices", "", 5000)) + .accept(MediaType.TEXT_HTML, MediaType.APPLICATION_JSON))); + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatus()); + assertTrue(ex.getResponse().getContentType().isPresent()); + assertEquals(MediaType.APPLICATION_JSON, ex.getResponse().getContentType().get().toString()); + Optional jsonOptional = ex.getResponse().getBody(String.class); + assertTrue(jsonOptional.isPresent()); + String json = jsonOptional.get(); + assertFalse(json.contains("")); + } + + @Test + void validationErrorsShowInHtmlErrorPages() { + BlockingHttpClient client = httpClient.toBlocking(); + HttpClientResponseException ex = assertThrows(HttpClientResponseException.class, () -> + client.exchange(HttpRequest.POST("/book/save", new Book("Building Microservices", "", 5000)) + .accept(MediaType.TEXT_HTML))); + assertEquals(HttpStatus.BAD_REQUEST, ex.getStatus()); + assertTrue(ex.getResponse().getContentType().isPresent()); + assertEquals(MediaType.TEXT_HTML, ex.getResponse().getContentType().get().toString()); + Optional htmlOptional = ex.getResponse().getBody(String.class); + assertTrue(htmlOptional.isPresent()); + String html = htmlOptional.get(); + assertExpectedSubstringInHtml("", html); + assertExpectedSubstringInHtml("book.author: must not be blank", html); + assertExpectedSubstringInHtml("book.pages: must be less than or equal to 4032", html); + + + ex = assertThrows(HttpClientResponseException.class, () -> client.exchange(HttpRequest.GET("/paginanoencontrada").accept(MediaType.TEXT_HTML))); + assertEquals(HttpStatus.NOT_FOUND, ex.getStatus()); + htmlOptional = ex.getResponse().getBody(String.class); + assertTrue(htmlOptional.isPresent()); + html = htmlOptional.get(); + assertExpectedSubstringInHtml("", html); + assertExpectedSubstringInHtml("Not Found", html); + assertExpectedSubstringInHtml("The page you were looking for doesn’t exist", html); + assertExpectedSubstringInHtml("You may have mistyped the address or the page may have moved", html); + + + ex = assertThrows(HttpClientResponseException.class, () -> client.exchange(HttpRequest.GET("/paginanoencontrada").header(HttpHeaders.ACCEPT_LANGUAGE, "es").accept(MediaType.TEXT_HTML))); + assertEquals(HttpStatus.NOT_FOUND, ex.getStatus()); + htmlOptional = ex.getResponse().getBody(String.class); + assertTrue(htmlOptional.isPresent()); + html = htmlOptional.get(); + assertExpectedSubstringInHtml("", html); + assertExpectedSubstringInHtml("No encontrado", html); + assertExpectedSubstringInHtml("La página que buscabas no existe", html); + assertExpectedSubstringInHtml("Es posible que haya escrito mal la dirección o que la página se haya movido.", html); + } + + private void assertExpectedSubstringInHtml(String expected, String html) { + if (!html.contains(expected)) { + LOG.trace("{}", html); + } + assertTrue(html.contains(expected)); + } + + @Requires(property = "spec.name", value = "DefaultHtmlBodyErrorResponseProviderTest") + @Controller("/book") + static class FooController { + + @Produces(MediaType.TEXT_HTML) + @Post("/save") + @Status(HttpStatus.CREATED) + void save(@Body @Valid Book book) { + throw new UnsupportedOperationException(); + } + } + + @Requires(property = "spec.name", value = "DefaultHtmlBodyErrorResponseProviderTest") + @Factory + static class MessageSourceFactory { + @Singleton + MessageSource createMessageSource() { + return new ResourceBundleMessageSource("i18n.messages"); + } + } + + @Introspected + record Book(@NotBlank String title, @NotBlank String author, @Max(4032) int pages) { + } +} diff --git a/http-server-netty/src/test/resources/i18n/messages_es.properties b/http-server-netty/src/test/resources/i18n/messages_es.properties index e9a12f56573..0f5a8eadb85 100644 --- a/http-server-netty/src/test/resources/i18n/messages_es.properties +++ b/http-server-netty/src/test/resources/i18n/messages_es.properties @@ -1,2 +1,5 @@ hello=Hola -welcome.name=Bienvenido {0} \ No newline at end of file +welcome.name=Bienvenido {0} +404.error.bold=La página que buscabas no existe +404.error.title=No encontrado +404.error=Es posible que haya escrito mal la dirección o que la página se haya movido. \ No newline at end of file diff --git a/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/exceptions/HtmlErrorPageTest.java b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/exceptions/HtmlErrorPageTest.java new file mode 100644 index 00000000000..49ed714e284 --- /dev/null +++ b/http-server-tck/src/main/java/io/micronaut/http/server/tck/tests/exceptions/HtmlErrorPageTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2017-2024 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.exceptions; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Introspected; +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.*; +import io.micronaut.http.tck.AssertionUtils; +import io.micronaut.http.tck.BodyAssertion; +import io.micronaut.http.tck.HttpResponseAssertion; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.NotBlank; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.Map; + +import static io.micronaut.http.tck.TestScenario.asserts; + +@SuppressWarnings({ + "java:S5960", // We're allowed assertions, as these are used in tests only + "checkstyle:MissingJavadocType", + "checkstyle:DesignForExtension" +}) +public class HtmlErrorPageTest { + private static final String SPEC_NAME = "HtmlErrorPageTest"; + + @Test + void htmlErrorPage() throws IOException { + asserts(SPEC_NAME, + HttpRequest.POST("/book/save", new Book("Building Microservices", "", 5000)).accept(MediaType.TEXT_HTML), + (server, request) -> AssertionUtils.assertThrows( + server, + request, + HttpResponseAssertion.builder() + .status(HttpStatus.BAD_REQUEST) + .body(BodyAssertion.builder().body("").contains()) + .body(BodyAssertion.builder().body("book.author: must not be blank").contains()) + .body(BodyAssertion.builder().body("book.pages: must be less than or equal to 4032").contains()) + .headers(Map.of(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_HTML)) + .build() + ) + ); + } + + @Requires(property = "spec.name", value = "HtmlErrorPageTest") + @Controller("/book") + static class FooController { + + @Produces(MediaType.TEXT_HTML) + @Post("/save") + @Status(HttpStatus.CREATED) + void save(@Body @Valid Book book) { + throw new UnsupportedOperationException(); + } + } + + @Introspected + record Book(@NotBlank String title, @NotBlank String author, @Max(4032) int pages) { + + } +} diff --git a/http-server/build.gradle b/http-server/build.gradle index efd70a41a09..2c282eea4a2 100644 --- a/http-server/build.gradle +++ b/http-server/build.gradle @@ -15,6 +15,21 @@ dependencies { annotationProcessor project(":inject-java") testImplementation libs.managed.netty.codec.http + + testAnnotationProcessor(project(":inject-java")) + testAnnotationProcessor platform(libs.test.boms.micronaut.validation) + testAnnotationProcessor (libs.micronaut.validation.processor) { + exclude group: 'io.micronaut' + } + testImplementation platform(libs.test.boms.micronaut.validation) + testImplementation (libs.micronaut.validation) { + exclude group: 'io.micronaut' + } + testImplementation(libs.micronaut.test.junit5) { + exclude group: 'io.micronaut' + } + testImplementation(libs.junit.jupiter.api) + testImplementation(libs.junit.jupiter.params) } //compileTestGroovy.groovyOptions.forkOptions.jvmArgs = ['-Xdebug', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005'] diff --git a/http-server/src/main/java/io/micronaut/http/server/exceptions/response/DefaultErrorResponseProcessor.java b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/DefaultErrorResponseProcessor.java new file mode 100644 index 00000000000..b31a913650f --- /dev/null +++ b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/DefaultErrorResponseProcessor.java @@ -0,0 +1,61 @@ +/* + * Copyright 2017-2024 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.exceptions.response; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.http.*; +import io.micronaut.http.hateoas.JsonError; +import jakarta.inject.Singleton; + +/** + * Default implementation of {@link ErrorResponseProcessor}. + * It delegates to {@link JsonErrorResponseBodyProvider} for JSON responses and to {@link HtmlErrorResponseBodyProvider} for HTML responses. + * + * @author Sergio del Amo + * @since 4.7.0 + */ +@Internal +@Singleton +@Requires(missingBeans = ErrorResponseProcessor.class) +final class DefaultErrorResponseProcessor implements ErrorResponseProcessor { + private final JsonErrorResponseBodyProvider jsonBodyErrorResponseProvider; + private final HtmlErrorResponseBodyProvider htmlBodyErrorResponseProvider; + + DefaultErrorResponseProcessor(JsonErrorResponseBodyProvider jsonBodyErrorResponseProvider, + HtmlErrorResponseBodyProvider htmlBodyErrorResponseProvider) { + this.jsonBodyErrorResponseProvider = jsonBodyErrorResponseProvider; + this.htmlBodyErrorResponseProvider = htmlBodyErrorResponseProvider; + } + + @Override + public MutableHttpResponse processResponse(ErrorContext errorContext, MutableHttpResponse response) { + HttpRequest request = errorContext.getRequest(); + if (request.getMethod() == HttpMethod.HEAD) { + return (MutableHttpResponse) response; + } + final boolean isError = response.status().getCode() >= 400; + if (isError + && request.accept().stream().anyMatch(mediaType -> mediaType.equals(MediaType.TEXT_HTML_TYPE)) + && request.accept().stream().noneMatch(m -> m.matchesExtension(MediaType.EXTENSION_JSON)) + ) { + return response.body(htmlBodyErrorResponseProvider.body(errorContext, response)) + .contentType(htmlBodyErrorResponseProvider.contentType()); + } + return response.body(jsonBodyErrorResponseProvider.body(errorContext, response)) + .contentType(jsonBodyErrorResponseProvider.contentType()); + } +} diff --git a/http-server/src/main/java/io/micronaut/http/server/exceptions/response/DefaultHtmlErrorResponseBodyProvider.java b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/DefaultHtmlErrorResponseBodyProvider.java new file mode 100644 index 00000000000..f02297834d7 --- /dev/null +++ b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/DefaultHtmlErrorResponseBodyProvider.java @@ -0,0 +1,203 @@ +/* + * Copyright 2017-2024 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.exceptions.response; + +import io.micronaut.context.MessageSource; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.util.LocaleResolver; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.util.HtmlSanitizer; +import jakarta.inject.Singleton; + +import java.text.MessageFormat; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import static io.micronaut.http.HttpStatus.*; + +/** + * It generates HTML error response page for a given {@link HttpStatus}. + * @author Sergio del Amo + * @since 4.7.0 + */ +@Internal +@Singleton +@Requires(missingBeans = HtmlErrorResponseBodyProvider.class) +final class DefaultHtmlErrorResponseBodyProvider implements HtmlErrorResponseBodyProvider { + private static final Map DEFAULT_ERROR_BOLD = Map.of( + NOT_FOUND.getCode(), "The page you were looking for doesn’t exist", + REQUEST_ENTITY_TOO_LARGE.getCode(), "The file or data you are trying to upload exceeds the allowed size" + ); + + private static final Map DEFAULT_ERROR = Map.of( + NOT_FOUND.getCode(), "You may have mistyped the address or the page may have moved", + REQUEST_ENTITY_TOO_LARGE.getCode(), "Please try again with a smaller file" + ); + + private static final String CSS = """ + *, *::before, *::after { + box-sizing: border-box; + } + * { + margin: 0; + } + html { + font-size: 16px; + } + h2 { + margin-top: -0.95em; + font-size: 6em; + opacity: .2; + } + body { + background: #2559a7; + color: #FFF; + display: grid; + font-family: -apple-system, "Helvetica Neue", Helvetica, sans-serif; + font-size: clamp(1rem, 2.5vw, 2rem); + -webkit-font-smoothing: antialiased; + font-style: normal; + font-weight: 400; + letter-spacing: -0.0025em; + line-height: 1.4; + min-height: 100vh; + place-items: center; + text-rendering: optimizeLegibility; + -webkit-text-size-adjust: 100%; + } + a { + color: inherit; + font-weight: 700; + text-decoration: underline; + text-underline-offset: 0.0925em; + } + b, strong { + font-weight: 700; + } + i, em { + font-style: italic; + } + main { + display: grid; + gap: 1em; + padding: 2em; + place-items: center; + text-align: center; + } + main header { + width: min(100%, 18em); + } + main header svg { + height: auto; + max-width: 100%; + width: 100%; + } + main article { + margin-top: -0.95em; + width: min(100%, 30em); + } + main article p { + font-size: 75%; + } + main article br { + display: none; + @media(min-width: 48em) { + display: inline; + } + } + """; + + private final HtmlSanitizer htmlSanitizer; + private final MessageSource messageSource; + private final LocaleResolver> localeResolver; + private final Map cache = new ConcurrentHashMap<>(); + + DefaultHtmlErrorResponseBodyProvider(HtmlSanitizer htmlSanitizer, + MessageSource messageSource, + LocaleResolver> localeResolver) { + this.htmlSanitizer = htmlSanitizer; + this.messageSource = messageSource; + this.localeResolver = localeResolver; + } + + @Override + public String body(@NonNull ErrorContext errorContext, @NonNull HttpResponse response) { + int httpStatusCode = response.code(); + String httpStatusReason = htmlSanitizer.sanitize(response.reason()); + Locale locale = localeResolver.resolveOrDefault(errorContext.getRequest()); + return cache.computeIfAbsent(new LocaleStatus(locale, httpStatusCode), key -> html(locale, httpStatusCode, httpStatusReason, errorContext)); + } + + private String html(Locale locale, + int httpStatusCode, + String httpStatusReason, + ErrorContext errorContext) { + final String errorTitleCode = httpStatusCode + ".error.title"; + final String errorTitle = messageSource.getMessage(errorTitleCode, httpStatusReason, locale); + String header = "

" + errorTitle + "

"; + header += "

" + httpStatusCode + "

"; + return MessageFormat.format("{0} — {1}
{3}
{4}
", + httpStatusCode, + errorTitle, + CSS, + header, + article(locale, httpStatusCode, httpStatusReason, errorContext)); + } + + private String article(Locale locale, + int httpStatusCode, + String httpStatusReason, + ErrorContext errorContext) { + final String errorBoldCode = httpStatusCode + ".error.bold"; + final String errorCode = httpStatusCode + ".error"; + String defaultErrorBold = DEFAULT_ERROR_BOLD.get(httpStatusCode); + String defaultError = DEFAULT_ERROR.get(httpStatusCode); + String errorBold = defaultErrorBold != null ? messageSource.getMessage(errorBoldCode, defaultErrorBold, locale) : messageSource.getMessage(errorBoldCode, locale).orElse(null); + String error = defaultError != null ? messageSource.getMessage(errorCode, defaultError, locale) : messageSource.getMessage(errorCode, locale).orElse(null); + StringBuilder sb = new StringBuilder(); + + for (io.micronaut.http.server.exceptions.response.Error e : errorContext.getErrors()) { + if (!e.getMessage().equalsIgnoreCase(httpStatusReason)) { + sb.append(htmlSanitizer.sanitize(e.getMessage())); + sb.append("
"); + } + } + + if (error != null || errorBold != null) { + sb.append("

"); + if (errorBold != null) { + sb.append(""); + sb.append(errorBold); + sb.append(". "); + } + if (error != null) { + sb.append(error); + sb.append("."); + } + sb.append("

"); + } + return sb.toString(); + } + + private record LocaleStatus(Locale locale, int httpStatusCode) { + + } +} diff --git a/http-server/src/main/java/io/micronaut/http/server/exceptions/response/DefaultJsonErrorResponseBodyProvider.java b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/DefaultJsonErrorResponseBodyProvider.java new file mode 100644 index 00000000000..d478ba0a427 --- /dev/null +++ b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/DefaultJsonErrorResponseBodyProvider.java @@ -0,0 +1,69 @@ +/* + * Copyright 2017-2024 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.exceptions.response; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.Internal; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.hateoas.JsonError; +import io.micronaut.http.hateoas.Link; +import io.micronaut.http.hateoas.Resource; +import io.micronaut.json.JsonConfiguration; +import jakarta.inject.Singleton; + +import java.util.ArrayList; +import java.util.List; + +/** + * Default implementation of {@link JsonErrorResponseBodyProvider} which returns a {@link JsonError}. + * + * @since 4.7.0 + */ +@Internal +@Singleton +@Requires(missingBeans = JsonErrorResponseBodyProvider.class) +final class DefaultJsonErrorResponseBodyProvider implements JsonErrorResponseBodyProvider { + private final boolean alwaysSerializeErrorsAsList; + + DefaultJsonErrorResponseBodyProvider(JsonConfiguration jacksonConfiguration) { + this.alwaysSerializeErrorsAsList = jacksonConfiguration.isAlwaysSerializeErrorsAsList(); + } + + @Override + public JsonError body(ErrorContext errorContext, HttpResponse response) { + JsonError error; + if (!errorContext.hasErrors()) { + error = new JsonError(response.reason()); + } else if (errorContext.getErrors().size() == 1 && !alwaysSerializeErrorsAsList) { + Error jsonError = errorContext.getErrors().get(0); + error = new JsonError(jsonError.getMessage()); + jsonError.getPath().ifPresent(error::path); + } else { + error = new JsonError(response.reason()); + List errors = new ArrayList<>(errorContext.getErrors().size()); + for (Error jsonError : errorContext.getErrors()) { + errors.add(new JsonError(jsonError.getMessage()).path(jsonError.getPath().orElse(null))); + } + error.embedded("errors", errors); + } + try { + error.link(Link.SELF, Link.of(errorContext.getRequest().getUri())); + } catch (IllegalArgumentException ignored) { + // invalid URI, don't include it + } + return error; + } +} diff --git a/http-server/src/main/java/io/micronaut/http/server/exceptions/response/ErrorResponseBodyProvider.java b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/ErrorResponseBodyProvider.java new file mode 100644 index 00000000000..6c48e454def --- /dev/null +++ b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/ErrorResponseBodyProvider.java @@ -0,0 +1,43 @@ +/* + * Copyright 2017-2024 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.exceptions.response; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.http.HttpResponse; + +/** + * Provides an HTTP Response body of an error response. + * @author Sergio del Amo + * @since 4.7.0 + * @param The body type + */ +public interface ErrorResponseBodyProvider { + /** + * + * @param errorContext Error Context + * @param response Base HTTP Response + * @return The HTTP Response Body + */ + @NonNull + T body(@NonNull ErrorContext errorContext, @NonNull HttpResponse response); + + /** + * @return The content type of the HTTP response + */ + @NonNull + String contentType(); + +} diff --git a/http-server/src/main/java/io/micronaut/http/server/exceptions/response/ErrorResponseProcessor.java b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/ErrorResponseProcessor.java index 37fd3d27cd7..9c4fa449ee4 100644 --- a/http-server/src/main/java/io/micronaut/http/server/exceptions/response/ErrorResponseProcessor.java +++ b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/ErrorResponseProcessor.java @@ -26,7 +26,7 @@ * @author James Kleeh * @since 2.4.0 */ -@DefaultImplementation(HateoasErrorResponseProcessor.class) +@DefaultImplementation(DefaultErrorResponseProcessor.class) public interface ErrorResponseProcessor { /** diff --git a/http-server/src/main/java/io/micronaut/http/server/exceptions/response/HateoasErrorResponseProcessor.java b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/HateoasErrorResponseProcessor.java index 007182c9160..0d3e12154ef 100644 --- a/http-server/src/main/java/io/micronaut/http/server/exceptions/response/HateoasErrorResponseProcessor.java +++ b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/HateoasErrorResponseProcessor.java @@ -15,7 +15,6 @@ */ package io.micronaut.http.server.exceptions.response; -import io.micronaut.context.annotation.Secondary; import io.micronaut.core.annotation.NonNull; import io.micronaut.http.HttpMethod; import io.micronaut.http.MediaType; @@ -24,8 +23,6 @@ import io.micronaut.http.hateoas.Link; import io.micronaut.http.hateoas.Resource; import io.micronaut.json.JsonConfiguration; -import jakarta.inject.Singleton; - import java.util.ArrayList; import java.util.List; @@ -34,9 +31,9 @@ * * @author James Kleeh * @since 2.4.0 + * @deprecated use {@link io.micronaut.http.server.exceptions.response.DefaultErrorResponseProcessor} instead */ -@Singleton -@Secondary +@Deprecated(forRemoval = true) public class HateoasErrorResponseProcessor implements ErrorResponseProcessor { private final boolean alwaysSerializeErrorsAsList; @@ -60,7 +57,7 @@ public MutableHttpResponse processResponse(@NonNull ErrorContext erro jsonError.getPath().ifPresent(error::path); } else { error = new JsonError(response.reason()); - List errors = new ArrayList<>(); + List errors = new ArrayList<>(errorContext.getErrors().size()); for (Error jsonError : errorContext.getErrors()) { errors.add(new JsonError(jsonError.getMessage()).path(jsonError.getPath().orElse(null))); } diff --git a/http-server/src/main/java/io/micronaut/http/server/exceptions/response/HtmlErrorResponseBodyProvider.java b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/HtmlErrorResponseBodyProvider.java new file mode 100644 index 00000000000..a45b142f74e --- /dev/null +++ b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/HtmlErrorResponseBodyProvider.java @@ -0,0 +1,34 @@ +/* + * Copyright 2017-2024 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.exceptions.response; + +import io.micronaut.context.annotation.DefaultImplementation; +import io.micronaut.http.MediaType; + +/** + * A {@link ErrorResponseBodyProvider} for HTML responses. + * Responses with content type {@link io.micronaut.http.MediaType#TEXT_HTML}. + * @author Sergio del Amo + * @since 4.7.0 + */ +@DefaultImplementation(DefaultHtmlErrorResponseBodyProvider.class) +public interface HtmlErrorResponseBodyProvider extends ErrorResponseBodyProvider { + + @Override + default String contentType() { + return MediaType.TEXT_HTML; + } +} diff --git a/http-server/src/main/java/io/micronaut/http/server/exceptions/response/JsonErrorResponseBodyProvider.java b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/JsonErrorResponseBodyProvider.java new file mode 100644 index 00000000000..0a7d6cc0369 --- /dev/null +++ b/http-server/src/main/java/io/micronaut/http/server/exceptions/response/JsonErrorResponseBodyProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2024 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.exceptions.response; + +import io.micronaut.http.MediaType; + +/** + * A {@link ErrorResponseBodyProvider} for JSON responses. + * Responses with content type {@link io.micronaut.http.MediaType#APPLICATION_JSON}. + * @author Sergio del Amo + * @since 4.7.0 + * @param The error type + */ +@FunctionalInterface +public interface JsonErrorResponseBodyProvider extends ErrorResponseBodyProvider { + @Override + default String contentType() { + return MediaType.APPLICATION_JSON; + } +} diff --git a/http-server/src/test/java/io/micronaut/http/server/exceptions/response/HtmlErrorResponseBodyProviderTest.java b/http-server/src/test/java/io/micronaut/http/server/exceptions/response/HtmlErrorResponseBodyProviderTest.java new file mode 100644 index 00000000000..fc5ea273cf6 --- /dev/null +++ b/http-server/src/test/java/io/micronaut/http/server/exceptions/response/HtmlErrorResponseBodyProviderTest.java @@ -0,0 +1,100 @@ +package io.micronaut.http.server.exceptions.response; + +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.convert.value.MutableConvertibleValues; +import io.micronaut.http.*; +import io.micronaut.http.simple.SimpleHttpRequest; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import spock.lang.Specification; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +@MicronautTest(startApplication = false) +class HtmlErrorResponseBodyProviderTest extends Specification { + private static final Logger LOG = LoggerFactory.getLogger(HtmlErrorResponseBodyProviderTest.class); + + @Inject + HtmlErrorResponseBodyProvider htmlProvider; + + @ParameterizedTest + @EnumSource(HttpStatus.class) + void htmlPageforStatus(HttpStatus status) { + if (status.getCode() >= 400) { + + ErrorContext errorContext = new ErrorContext() { + @Override + public @NonNull HttpRequest getRequest() { + return new SimpleHttpRequest(HttpMethod.GET, "/foobar", null); + } + + @Override + public @NonNull Optional getRootCause() { + return Optional.empty(); + } + + @Override + public @NonNull List getErrors() { + return Collections.emptyList(); + } + }; + HttpResponse response = new HttpResponse() { + @Override + public HttpStatus getStatus() { + return status; + } + + @Override + public int code() { + return status.getCode(); + } + + @Override + public String reason() { + return status.getReason(); + } + + @Override + public HttpHeaders getHeaders() { + return null; + } + + @Override + public MutableConvertibleValues getAttributes() { + return null; + } + + @Override + public Optional getBody() { + return Optional.empty(); + } + }; + String html = htmlProvider.body(errorContext, response); + + assertNotNull(html); + assertExpectedSubstringInHtml(status.getReason(), html); + assertExpectedSubstringInHtml("", html); + if (status.getCode() == 404) { + assertExpectedSubstringInHtml("The page you were looking for doesn’t exist", html); + assertExpectedSubstringInHtml("You may have mistyped the address or the page may have moved", html); + } else if (status.getCode() == 413) { + assertExpectedSubstringInHtml("The file or data you are trying to upload exceeds the allowed size", html); + assertExpectedSubstringInHtml("Please try again with a smaller file", html); + } + } + } + + private void assertExpectedSubstringInHtml(String expected, String html) { + if (!html.contains(expected)) { + LOG.trace("{}", html); + } + assertTrue(html.contains(expected)); + } +} diff --git a/http-tck/src/main/java/io/micronaut/http/tck/AssertionUtils.java b/http-tck/src/main/java/io/micronaut/http/tck/AssertionUtils.java index 8deac8ccb89..d5b5f0ebeed 100644 --- a/http-tck/src/main/java/io/micronaut/http/tck/AssertionUtils.java +++ b/http-tck/src/main/java/io/micronaut/http/tck/AssertionUtils.java @@ -19,16 +19,14 @@ import io.micronaut.core.annotation.NonNull; import io.micronaut.core.annotation.Nullable; import io.micronaut.core.type.Argument; -import io.micronaut.http.HttpHeaders; -import io.micronaut.http.HttpRequest; -import io.micronaut.http.HttpResponse; -import io.micronaut.http.HttpStatus; +import io.micronaut.http.*; import io.micronaut.http.client.HttpClient; 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.List; import java.util.Map; import java.util.Optional; import java.util.function.BiConsumer; @@ -60,7 +58,7 @@ public static void assertThrows(@NonNull ServerUnderTest server, @NonNull HttpRequest request, @NonNull HttpResponseAssertion assertion) { Executable e = assertion.getBody() != null ? - () -> server.exchange(request, Argument.of(assertion.getBody().getBodyType()), errorType(assertion)) : + () -> server.exchange(request, Argument.of(assertion.getBody().stream().map(BodyAssertion::getBodyType).findFirst().orElseThrow()), errorType(assertion)) : () -> server.exchange(request); HttpClientResponseException thrown = Assertions.assertThrows(HttpClientResponseException.class, e); HttpResponse response = thrown.getResponse(); @@ -75,11 +73,15 @@ private static Argument errorType(HttpResponseAssertion assertion) { if (assertion.getBody() == null) { return HttpClient.DEFAULT_ERROR_TYPE; } - if (assertion.getBody().getErrorType() == null) { - return HttpClient.DEFAULT_ERROR_TYPE; - } - return Argument.of(assertion.getBody().getErrorType()); - + return assertion.getBody() + .stream() + .map(BodyAssertion::getErrorType) + .findFirst() + .map(Argument::of) + .orElseGet(() -> { + Argument defaultErrorType = HttpClient.DEFAULT_ERROR_TYPE; + return defaultErrorType; + }); } public static void assertThrows(@NonNull ServerUnderTest server, @@ -110,7 +112,7 @@ public static void assertDoesNotThrow(@NonNull ServerUnderTest server, @NonNull HttpRequest request, @NonNull HttpResponseAssertion assertion) { ThrowingSupplier> executable = assertion.getBody() != null ? - () -> server.exchange(request, Argument.of(assertion.getBody().getBodyType()), errorType(assertion)) : + () -> server.exchange(request, Argument.of(assertion.getBody().stream().map(BodyAssertion::getBodyType).findFirst().orElseThrow()), errorType(assertion)) : () -> server.exchange(request); HttpResponse response = Assertions.assertDoesNotThrow(executable); assertEquals(assertion.getHttpStatus(), response.getStatus()); @@ -119,10 +121,12 @@ public static void assertDoesNotThrow(@NonNull ServerUnderTest server, assertion.getResponseConsumer().ifPresent(httpResponseConsumer -> httpResponseConsumer.accept(response)); } - private static void assertBody(@NonNull HttpResponse response, @Nullable BodyAssertion bodyAssertion) { - if (bodyAssertion != null) { - Optional bodyOptional = response.getBody(bodyAssertion.getBodyType()); - bodyAssertion.evaluate(bodyOptional.orElse(null)); + private static void assertBody(@NonNull HttpResponse response, @Nullable List> bodyAssertions) { + if (bodyAssertions != null) { + for (BodyAssertion bodyAssertion : bodyAssertions) { + Optional bodyOptional = response.getBody(bodyAssertion.getBodyType()); + bodyAssertion.evaluate(bodyOptional.orElse(null)); + } } } diff --git a/http-tck/src/main/java/io/micronaut/http/tck/BodyAssertion.java b/http-tck/src/main/java/io/micronaut/http/tck/BodyAssertion.java index 34c8458f7e2..e9a02d71c3c 100644 --- a/http-tck/src/main/java/io/micronaut/http/tck/BodyAssertion.java +++ b/http-tck/src/main/java/io/micronaut/http/tck/BodyAssertion.java @@ -80,8 +80,8 @@ public static BodyAssertion.Builder builder() { * @param body The HTTP Response Body */ @SuppressWarnings("java:S5960") // Assertion is the whole point of this method - public void evaluate(T body) { - assertTrue(this.evaluator.test(expected, body), () -> this.evaluator.message(expected, body)); + public void evaluate(Object body) { + assertTrue(this.evaluator.test(expected, (T) body), () -> this.evaluator.message(expected, (T) body)); } /** diff --git a/http-tck/src/main/java/io/micronaut/http/tck/HttpResponseAssertion.java b/http-tck/src/main/java/io/micronaut/http/tck/HttpResponseAssertion.java index 1ce187026bb..e506ee6cd06 100644 --- a/http-tck/src/main/java/io/micronaut/http/tck/HttpResponseAssertion.java +++ b/http-tck/src/main/java/io/micronaut/http/tck/HttpResponseAssertion.java @@ -21,10 +21,7 @@ import io.micronaut.http.HttpResponse; import io.micronaut.http.HttpStatus; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; +import java.util.*; import java.util.function.Consumer; /** @@ -36,18 +33,18 @@ public final class HttpResponseAssertion { private final HttpStatus httpStatus; private final Map headers; - private final BodyAssertion bodyAssertion; + private final List> bodyAssertions; @Nullable private final Consumer> responseConsumer; private HttpResponseAssertion(HttpStatus httpStatus, Map headers, - BodyAssertion bodyAssertion, + List> bodyAssertions, @Nullable Consumer> responseConsumer) { this.httpStatus = httpStatus; this.headers = headers; - this.bodyAssertion = bodyAssertion; + this.bodyAssertions = bodyAssertions; this.responseConsumer = responseConsumer; } @@ -77,8 +74,8 @@ public Map getHeaders() { * @return Expected HTTP Response body */ - public BodyAssertion getBody() { - return bodyAssertion; + public List> getBody() { + return bodyAssertions; } /** @@ -95,7 +92,7 @@ public static HttpResponseAssertion.Builder builder() { public static class Builder { private HttpStatus httpStatus; private Map headers; - private BodyAssertion bodyAssertion; + private List> bodyAssertions; private Consumer> responseConsumer; @@ -139,8 +136,7 @@ public Builder header(String headerName, String headerValue) { * @return HTTP Response Assertion Builder */ public Builder body(String containsBody) { - this.bodyAssertion = BodyAssertion.builder().body(containsBody).contains(); - return this; + return body(BodyAssertion.builder().body(containsBody).contains()); } /** @@ -149,7 +145,10 @@ public Builder body(String containsBody) { * @return HTTP Response Assertion Builder */ public Builder body(BodyAssertion bodyAssertion) { - this.bodyAssertion = bodyAssertion; + if (this.bodyAssertions == null) { + this.bodyAssertions = new ArrayList<>(); + } + this.bodyAssertions.add(bodyAssertion); return this; } @@ -168,7 +167,7 @@ public Builder status(HttpStatus httpStatus) { * @return HTTP Response Assertion */ public HttpResponseAssertion build() { - return new HttpResponseAssertion(Objects.requireNonNull(httpStatus), headers, bodyAssertion, responseConsumer); + return new HttpResponseAssertion(Objects.requireNonNull(httpStatus), headers, bodyAssertions, responseConsumer); } } } diff --git a/http/build.gradle b/http/build.gradle index 9c40ac1d5e9..fb64334a79e 100644 --- a/http/build.gradle +++ b/http/build.gradle @@ -22,6 +22,7 @@ dependencies { testImplementation project(":runtime") testImplementation(libs.logback.classic) testImplementation(libs.jazzer.junit) + testImplementation(libs.junit.jupiter.params) } tasks.named("compileKotlin") { diff --git a/http/src/main/java/io/micronaut/http/MediaType.java b/http/src/main/java/io/micronaut/http/MediaType.java index a6f398d5b13..db86b174f37 100644 --- a/http/src/main/java/io/micronaut/http/MediaType.java +++ b/http/src/main/java/io/micronaut/http/MediaType.java @@ -910,6 +910,16 @@ public boolean matchesType(String matchType) { return type.equals(WILDCARD) || type.equalsIgnoreCase(matchType); } + /** + * Check if the extension matches. + * @param matchExtension The extension to match + * @return true if matches + * @since 4.7.0 + */ + public boolean matchesAllOrWildcardOrExtension(String matchExtension) { + return extension.equalsIgnoreCase(ALL_TYPE.extension) || extension.equals(WILDCARD) || matchesExtension(matchExtension); + } + /** * Check if the extension matches. * @param matchExtension The extension to match @@ -917,7 +927,7 @@ public boolean matchesType(String matchType) { * @since 4.6.3 */ public boolean matchesExtension(String matchExtension) { - return extension.equalsIgnoreCase(ALL_TYPE.extension) || extension.equals(WILDCARD) || extension.equalsIgnoreCase(matchExtension); + return extension.equalsIgnoreCase(matchExtension); } /** diff --git a/http/src/main/java/io/micronaut/http/util/HtmlEntityEncodingHtmlSanitizer.java b/http/src/main/java/io/micronaut/http/util/HtmlEntityEncodingHtmlSanitizer.java new file mode 100644 index 00000000000..57137f8ef3e --- /dev/null +++ b/http/src/main/java/io/micronaut/http/util/HtmlEntityEncodingHtmlSanitizer.java @@ -0,0 +1,56 @@ +/* + * Copyright 2017-2024 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.util; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import jakarta.annotation.Nullable; +import jakarta.inject.Singleton; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Given an HTML string, it encodes the following characters: {@code &} to {@code &}, {@code <} to {@code <}, {@code >} to {@code >}, {@code "} to {@code "}, and {@code '} to {@code '}. + * @see Cross site Scripting Prevention Cheat Sheet + */ +@Singleton +@Requires(missingBeans = HtmlSanitizer.class) +public class HtmlEntityEncodingHtmlSanitizer implements HtmlSanitizer { + private final Map encodedMap; + + public HtmlEntityEncodingHtmlSanitizer() { + encodedMap = new LinkedHashMap<>(); + encodedMap.put("&", "&"); + encodedMap.put("<", "<"); + encodedMap.put(">", ">"); + encodedMap.put("\"", """); + encodedMap.put("'", "'"); + } + + @Override + @NonNull + public String sanitize(@Nullable String html) { + if (html == null) { + return ""; + } + String sanitized = html; + for (Map.Entry entry : encodedMap.entrySet()) { + sanitized = sanitized.replaceAll(entry.getKey(), entry.getValue()); + } + return sanitized; + } +} diff --git a/http/src/main/java/io/micronaut/http/util/HtmlSanitizer.java b/http/src/main/java/io/micronaut/http/util/HtmlSanitizer.java new file mode 100644 index 00000000000..54494c97562 --- /dev/null +++ b/http/src/main/java/io/micronaut/http/util/HtmlSanitizer.java @@ -0,0 +1,35 @@ +/* + * Copyright 2017-2024 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.util; + +import io.micronaut.core.annotation.NonNull; +import jakarta.annotation.Nullable; + +/** + * API to sanitize a String of HTML. + * @author Sergio del Amo + * @since 4.7.0 + */ +@FunctionalInterface +public interface HtmlSanitizer { + /** + * Sanitizes a string of HTML. + * @param html the String of HTML to Sanitize + * @return a sanitized version of the supplied HTML String. + */ + @NonNull + String sanitize(@Nullable String html); +} diff --git a/http/src/test/java/io/micronaut/http/MediaTypeTest.java b/http/src/test/java/io/micronaut/http/MediaTypeTest.java new file mode 100644 index 00000000000..db7d36dffc8 --- /dev/null +++ b/http/src/test/java/io/micronaut/http/MediaTypeTest.java @@ -0,0 +1,86 @@ +package io.micronaut.http; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; + +import static io.micronaut.http.MediaType.*; +import static org.junit.jupiter.api.Assertions.*; + +class MediaTypeTest { + @ParameterizedTest + @MethodSource + void isJsonTrue(MediaType mediaType) { + assertTrue(mediaType.matchesAllOrWildcardOrExtension(MediaType.EXTENSION_JSON)); + assertTrue(mediaType.matchesExtension(MediaType.EXTENSION_JSON)); + } + + private static List isJsonTrue() { + return List.of( + APPLICATION_JSON_TYPE, + TEXT_JSON_TYPE, + APPLICATION_HAL_JSON_TYPE, + APPLICATION_JSON_GITHUB_TYPE, + APPLICATION_JSON_FEED_TYPE, + APPLICATION_JSON_PROBLEM_TYPE, + APPLICATION_JSON_PATCH_TYPE, + APPLICATION_JSON_MERGE_PATCH_TYPE, + APPLICATION_JSON_SCHEMA_TYPE, + APPLICATION_VND_ERROR_TYPE + ); + } + + @ParameterizedTest + @MethodSource + void isJsonFalse(MediaType mediaType) { + assertFalse(mediaType.matchesExtension(MediaType.EXTENSION_JSON)); + } + + private static List isJsonFalse() { + return List.of(ALL_TYPE, + APPLICATION_FORM_URLENCODED_TYPE, + APPLICATION_XHTML_TYPE, + APPLICATION_XML_TYPE, + APPLICATION_YAML_TYPE, + APPLICATION_HAL_XML_TYPE, + APPLICATION_ATOM_XML_TYPE, + APPLICATION_JSON_STREAM_TYPE, + APPLICATION_OCTET_STREAM_TYPE, + APPLICATION_GRAPHQL_TYPE, + APPLICATION_PDF_TYPE, + GPX_XML_TYPE, + GZIP_TYPE, + ZIP_TYPE, + MICROSOFT_EXCEL_OPEN_XML_TYPE, + MICROSOFT_EXCEL_TYPE, + YANG_TYPE, + CUE_TYPE, + TOML_TYPE, + RTF_TYPE, + ZLIB_TYPE, + ZSTD_TYPE, + MULTIPART_FORM_DATA_TYPE, + TEXT_HTML_TYPE, + TEXT_CSV_TYPE, + TEXT_XML_TYPE, + TEXT_PLAIN_TYPE, + TEXT_EVENT_STREAM_TYPE, + TEXT_MARKDOWN_TYPE, + TEXT_CSS_TYPE, + TEXT_JAVASCRIPT_TYPE, + TEXT_ECMASCRIPT_TYPE, + IMAGE_APNG_TYPE, + IMAGE_BMP_TYPE, + IMAGE_X_ICON_TYPE, + IMAGE_TIFF_TYPE, + IMAGE_AVIF_TYPE, + IMAGE_SVG_TYPE, + IMAGE_XBM_TYPE, + IMAGE_PNG_TYPE, + IMAGE_JPEG_TYPE, + IMAGE_GIF_TYPE, + IMAGE_WEBP_TYPE, + IMAGE_WMF_TYPE); + } +} diff --git a/http/src/test/java/io/micronaut/http/util/HtmlEntityEncodingHtmlSanitizerTest.java b/http/src/test/java/io/micronaut/http/util/HtmlEntityEncodingHtmlSanitizerTest.java new file mode 100644 index 00000000000..7b44edf578a --- /dev/null +++ b/http/src/test/java/io/micronaut/http/util/HtmlEntityEncodingHtmlSanitizerTest.java @@ -0,0 +1,50 @@ +package io.micronaut.http.util; + +import io.micronaut.context.ApplicationContext; +import io.micronaut.context.annotation.Requires; +import jakarta.inject.Singleton; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class HtmlEntityEncodingHtmlSanitizerTest { + @Test + void sanitize() { + HtmlEntityEncodingHtmlSanitizer sanitizer = new HtmlEntityEncodingHtmlSanitizer(); + String html = sanitizer.sanitize("Hello, World!"); + assertEquals("<b>Hello, World!</b>", html); + + html = sanitizer.sanitize("\"Hello, World!\""); + assertEquals(""Hello, World!"", html); + html = sanitizer.sanitize("'Hello, World!'"); + assertEquals("'Hello, World!'", html); + assertEquals("", sanitizer.sanitize(null)); + } + + @Test + void beanOfHtmlSanitizerExistsAndItDefaultsToHtmlEntityEncodingHtmlSanitizer() { + try (ApplicationContext ctx = ApplicationContext.run()) { + assertTrue(ctx.containsBean(HtmlSanitizer.class)); + assertTrue(ctx.getBean(HtmlSanitizer.class) instanceof HtmlEntityEncodingHtmlSanitizer); + } + } + + @Test + void itIsEasyToProvideYourOwnBeanOfTypeHtmlSanitizer() { + try (ApplicationContext ctx = ApplicationContext.run(Map.of("spec.name", "HtmlSanitizerReplacement"))) { + assertTrue(ctx.containsBean(HtmlSanitizer.class)); + assertTrue(ctx.getBean(HtmlSanitizer.class) instanceof BogusHtmlSanitizer); + } + } + + @Singleton + @Requires(property = "spec.name", value = "HtmlSanitizerReplacement") + static class BogusHtmlSanitizer implements HtmlSanitizer { + @Override + public String sanitize(String html) { + return "Bogus"; + } + } +} diff --git a/json-core/src/main/java/io/micronaut/json/body/JsonMessageHandler.java b/json-core/src/main/java/io/micronaut/json/body/JsonMessageHandler.java index 8111fe6e770..c04588bee46 100644 --- a/json-core/src/main/java/io/micronaut/json/body/JsonMessageHandler.java +++ b/json-core/src/main/java/io/micronaut/json/body/JsonMessageHandler.java @@ -85,7 +85,7 @@ public JsonMapper getJsonMapper() { @Override public boolean isReadable(@NonNull Argument type, MediaType mediaType) { - return mediaType != null && mediaType.matchesExtension(MediaType.EXTENSION_JSON); + return mediaType != null && mediaType.matchesAllOrWildcardOrExtension(MediaType.EXTENSION_JSON); } private static CodecException decorateRead(Argument type, IOException e) { @@ -122,7 +122,7 @@ public T read(@NonNull Argument type, MediaType mediaType, @NonNull Headers h @Override public boolean isWriteable(@NonNull Argument type, MediaType mediaType) { - return mediaType != null && mediaType.matchesExtension(MediaType.EXTENSION_JSON); + return mediaType != null && mediaType.matchesAllOrWildcardOrExtension(MediaType.EXTENSION_JSON); } private static CodecException decorateWrite(Object object, IOException e) { diff --git a/src/main/docs/guide/httpServer/errorHandling/errorFormatting.adoc b/src/main/docs/guide/httpServer/errorHandling/errorFormatting.adoc index 05b97cf6d19..b8b99362d17 100644 --- a/src/main/docs/guide/httpServer/errorHandling/errorFormatting.adoc +++ b/src/main/docs/guide/httpServer/errorHandling/errorFormatting.adoc @@ -1,5 +1,17 @@ -The Micronaut framework produces error response bodies via beans of type api:http.server.exceptions.response.ErrorResponseProcessor[]. +The Micronaut framework produces error responses via a bean of type api:http.server.exceptions.response.ErrorResponseProcessor[]. -The default response body is link:https://github.com/blongden/vnd.error[vnd.error], however you can create your own implementation of type api:http.server.exceptions.response.ErrorResponseProcessor[] to control the responses. +JSON error responses are provided with a bean of type api:http.server.exceptions.response.JsonErrorResponseBodyProvider[]. +The default implementation outputs link:https://github.com/blongden/vnd.error[vnd.error] responses. -If customization of the response other than items related to the errors is desired, the exception handler that is handling the exception needs to be overridden. +HTML error responses are provided via a bean of type api:http.server.exceptions.response.HtmlErrorResponseBodyProvider[]. +The default implementation outputs HTML which <> with codes such as: +`.error.bold`, `.error.title`, `.error`. For example, you could localize the default 404 error page into Spanish: + +[source,properties] +---- +404.error.bold=La página que buscabas no existe +404.error.title=No encontrado +404.error=Es posible que haya escrito mal la dirección o que la página se haya movido. +---- + +If customization of the response other than items related to the errors is desired, the <> that is handling the exception needs to be overridden.