forked from micronaut-projects/micronaut-core
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: nicer error pages for HTML responses (micronaut-projects#11210)
If the request accepts an HTML Response, respond a nice error page.
- Loading branch information
Showing
26 changed files
with
1,075 additions
and
47 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
134 changes: 134 additions & 0 deletions
134
...o/micronaut/http/server/exceptions/response/DefaultHtmlErrorResponseBodyProviderTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<String> jsonOptional = ex.getResponse().getBody(String.class); | ||
assertTrue(jsonOptional.isPresent()); | ||
String json = jsonOptional.get(); | ||
assertFalse(json.contains("<!doctype html>")); | ||
} | ||
|
||
@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<String> htmlOptional = ex.getResponse().getBody(String.class); | ||
assertTrue(htmlOptional.isPresent()); | ||
String html = htmlOptional.get(); | ||
assertExpectedSubstringInHtml("<!doctype html>", 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("<!doctype html>", 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("<!doctype html>", 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) { | ||
} | ||
} |
5 changes: 4 additions & 1 deletion
5
http-server-netty/src/test/resources/i18n/messages_es.properties
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,5 @@ | ||
hello=Hola | ||
welcome.name=Bienvenido {0} | ||
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. |
80 changes: 80 additions & 0 deletions
80
...er-tck/src/main/java/io/micronaut/http/server/tck/tests/exceptions/HtmlErrorPageTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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("<!doctype html>").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) { | ||
|
||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
61 changes: 61 additions & 0 deletions
61
...main/java/io/micronaut/http/server/exceptions/response/DefaultErrorResponseProcessor.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<JsonError>) 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()); | ||
} | ||
} |
Oops, something went wrong.