Skip to content

Commit df65ccf

Browse files
authored
Merge pull request #618 from felleslosninger/EIN-4937-html-feilrespons
EIN-4937: Add `/error` endpoint
2 parents 5d704be + 947076a commit df65ccf

File tree

5 files changed

+210
-0
lines changed

5 files changed

+210
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Auto-generated from our API specification
2+
// https://github.com/felleslosninger/einnsyn-api-spec
3+
4+
package no.einnsyn.backend.common.exceptions.models;
5+
6+
import lombok.Getter;
7+
import no.einnsyn.backend.common.responses.models.ErrorResponse;
8+
9+
@Getter
10+
public class TooManyRequestsException extends EInnsynException {
11+
12+
public TooManyRequestsException(String message, Throwable cause) {
13+
super(message, cause, "tooManyRequests");
14+
}
15+
16+
public TooManyRequestsException(String message) {
17+
super(message, "tooManyRequests");
18+
}
19+
20+
@Override
21+
public ErrorResponse toClientResponse() {
22+
return new ClientResponse(this.getMessage());
23+
}
24+
25+
@Getter
26+
public static class ClientResponse implements ErrorResponse {
27+
protected final String type = "tooManyRequests";
28+
29+
protected String message;
30+
31+
public ClientResponse(String message) {
32+
super();
33+
this.message = message;
34+
}
35+
}
36+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package no.einnsyn.backend.error;
2+
3+
import com.google.gson.Gson;
4+
import jakarta.servlet.RequestDispatcher;
5+
import jakarta.servlet.http.HttpServletRequest;
6+
import jakarta.servlet.http.HttpServletResponse;
7+
import java.io.IOException;
8+
import java.nio.charset.StandardCharsets;
9+
import no.einnsyn.backend.common.exceptions.models.AuthenticationException;
10+
import no.einnsyn.backend.common.exceptions.models.AuthorizationException;
11+
import no.einnsyn.backend.common.exceptions.models.BadRequestException;
12+
import no.einnsyn.backend.common.exceptions.models.ConflictException;
13+
import no.einnsyn.backend.common.exceptions.models.InternalServerErrorException;
14+
import no.einnsyn.backend.common.exceptions.models.MethodNotAllowedException;
15+
import no.einnsyn.backend.common.exceptions.models.NotFoundException;
16+
import no.einnsyn.backend.common.exceptions.models.TooManyRequestsException;
17+
import no.einnsyn.backend.common.responses.models.ErrorResponse;
18+
import org.springframework.boot.webmvc.error.ErrorController;
19+
import org.springframework.http.HttpStatus;
20+
import org.springframework.http.MediaType;
21+
import org.springframework.util.StringUtils;
22+
import org.springframework.web.bind.annotation.RequestMapping;
23+
import org.springframework.web.bind.annotation.RestController;
24+
25+
@RestController
26+
public class ApiErrorController implements ErrorController {
27+
28+
private final Gson gson;
29+
30+
public ApiErrorController(Gson gson) {
31+
this.gson = gson;
32+
}
33+
34+
@RequestMapping("/error")
35+
public void error(HttpServletRequest request, HttpServletResponse response) throws IOException {
36+
var status = resolveStatus(request);
37+
var path = (String) request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
38+
var message = resolveMessage(status, path, request);
39+
var errorResponse = buildErrorResponse(status, message);
40+
41+
// This is a JSON API. Never let /error fall back to view resolution.
42+
response.setStatus(status.value());
43+
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
44+
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
45+
response.getWriter().write(gson.toJson(errorResponse));
46+
}
47+
48+
private HttpStatus resolveStatus(HttpServletRequest request) {
49+
var statusCode = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
50+
if (statusCode instanceof Integer integerStatus) {
51+
var status = HttpStatus.resolve(integerStatus);
52+
if (status != null) {
53+
return status;
54+
}
55+
}
56+
return HttpStatus.INTERNAL_SERVER_ERROR;
57+
}
58+
59+
private String resolveMessage(HttpStatus status, String path, HttpServletRequest request) {
60+
var errorMessage = request.getAttribute(RequestDispatcher.ERROR_MESSAGE);
61+
if (errorMessage instanceof String message && !message.isBlank()) {
62+
return message;
63+
}
64+
65+
if (status == HttpStatus.NOT_FOUND && StringUtils.hasText(path)) {
66+
return "No handler found: " + path;
67+
}
68+
69+
return status.getReasonPhrase();
70+
}
71+
72+
private ErrorResponse buildErrorResponse(HttpStatus status, String message) {
73+
return switch (status) {
74+
case BAD_REQUEST -> new BadRequestException.ClientResponse(message);
75+
case UNAUTHORIZED -> new AuthenticationException.ClientResponse(message);
76+
case FORBIDDEN -> new AuthorizationException.ClientResponse(message);
77+
case NOT_FOUND -> new NotFoundException.ClientResponse(message);
78+
case METHOD_NOT_ALLOWED -> new MethodNotAllowedException.ClientResponse(message);
79+
case CONFLICT -> new ConflictException.ClientResponse(message);
80+
case TOO_MANY_REQUESTS -> new TooManyRequestsException.ClientResponse(message);
81+
default -> new InternalServerErrorException.ClientResponse(message);
82+
};
83+
}
84+
}

src/main/java/no/einnsyn/backend/error/EInnsynExceptionHandler.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import no.einnsyn.backend.common.exceptions.models.MethodNotAllowedException;
1515
import no.einnsyn.backend.common.exceptions.models.NetworkException;
1616
import no.einnsyn.backend.common.exceptions.models.NotFoundException;
17+
import no.einnsyn.backend.common.exceptions.models.TooManyRequestsException;
1718
import no.einnsyn.backend.common.exceptions.models.TooManyUnverifiedOrdersException;
1819
import no.einnsyn.backend.common.exceptions.models.ValidationException;
1920
import no.einnsyn.backend.common.exceptions.models.ValidationException.FieldError;
@@ -222,6 +223,20 @@ public ResponseEntity<Object> handleConflictException(ConflictException ex) {
222223
return ResponseEntity.status(httpStatus).body(clientResponse);
223224
}
224225

226+
/**
227+
* Too many requests.
228+
*
229+
* @param ex the exception
230+
* @return the response entity
231+
*/
232+
@ExceptionHandler(TooManyRequestsException.class)
233+
public ResponseEntity<Object> handleTooManyRequestsException(TooManyRequestsException ex) {
234+
var httpStatus = HttpStatus.TOO_MANY_REQUESTS;
235+
logAndCountWarning(ex, httpStatus);
236+
var clientResponse = ex.toClientResponse();
237+
return ResponseEntity.status(httpStatus).body(clientResponse);
238+
}
239+
225240
/**
226241
* Too many unverified orders.
227242
*

src/test/java/no/einnsyn/backend/common/exceptions/ErrorResponseTest.java

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import static org.junit.jupiter.api.Assertions.assertNotNull;
55
import static org.junit.jupiter.api.Assertions.assertTrue;
66

7+
import com.google.gson.JsonObject;
8+
import java.util.stream.Stream;
79
import no.einnsyn.backend.EinnsynControllerTestBase;
810
import no.einnsyn.backend.common.exceptions.models.AuthenticationException;
911
import no.einnsyn.backend.common.exceptions.models.AuthorizationException;
@@ -12,12 +14,17 @@
1214
import no.einnsyn.backend.common.exceptions.models.InternalServerErrorException;
1315
import no.einnsyn.backend.common.exceptions.models.MethodNotAllowedException;
1416
import no.einnsyn.backend.common.exceptions.models.NotFoundException;
17+
import no.einnsyn.backend.common.exceptions.models.TooManyRequestsException;
1518
import no.einnsyn.backend.common.exceptions.models.ValidationException;
1619
import no.einnsyn.backend.entities.arkiv.models.ArkivDTO;
1720
import no.einnsyn.backend.entities.arkivdel.models.ArkivdelDTO;
1821
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.params.ParameterizedTest;
23+
import org.junit.jupiter.params.provider.Arguments;
24+
import org.junit.jupiter.params.provider.MethodSource;
1925
import org.springframework.boot.test.context.SpringBootTest;
2026
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
27+
import org.springframework.http.HttpHeaders;
2128
import org.springframework.http.HttpStatus;
2229
import org.springframework.http.MediaType;
2330
import org.springframework.test.context.ActiveProfiles;
@@ -47,6 +54,42 @@ void testNotFound() throws Exception {
4754
assertNotNull(errorResponse.getMessage());
4855
}
4956

57+
@ParameterizedTest
58+
@MethodSource("browserAcceptErrorResponses")
59+
void testErrorResponsesWithBrowserAcceptHeaderReturnJson(
60+
String endpoint, HttpStatus expectedStatus, String expectedType) throws Exception {
61+
var headers = new HttpHeaders();
62+
headers.setAccept(
63+
MediaType.parseMediaTypes(
64+
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"));
65+
var response = get(endpoint, headers);
66+
67+
assertEquals(expectedStatus, response.getStatusCode());
68+
assertNotNull(response.getHeaders().getContentType());
69+
assertTrue(MediaType.APPLICATION_JSON.isCompatibleWith(response.getHeaders().getContentType()));
70+
var errorResponse = gson.fromJson(response.getBody(), JsonObject.class);
71+
assertEquals(expectedType, errorResponse.get("type").getAsString());
72+
assertNotNull(errorResponse.get("message"));
73+
}
74+
75+
private static Stream<Arguments> browserAcceptErrorResponses() {
76+
return Stream.of(
77+
Arguments.of("/notfound", HttpStatus.NOT_FOUND, "notFound"),
78+
Arguments.of("/validationTest/sendError/400", HttpStatus.BAD_REQUEST, "badRequest"),
79+
Arguments.of(
80+
"/validationTest/sendError/401", HttpStatus.UNAUTHORIZED, "authenticationError"),
81+
Arguments.of("/validationTest/sendError/403", HttpStatus.FORBIDDEN, "authorizationError"),
82+
Arguments.of(
83+
"/validationTest/sendError/405", HttpStatus.METHOD_NOT_ALLOWED, "methodNotAllowed"),
84+
Arguments.of("/validationTest/sendError/409", HttpStatus.CONFLICT, "conflict"),
85+
Arguments.of(
86+
"/validationTest/sendError/429", HttpStatus.TOO_MANY_REQUESTS, "tooManyRequests"),
87+
Arguments.of(
88+
"/validationTest/sendError/500",
89+
HttpStatus.INTERNAL_SERVER_ERROR,
90+
"internalServerError"));
91+
}
92+
5093
@Test
5194
void testMethodNotAllowedException() throws Exception {
5295
var journalpostJSON = getJournalpostJSON();
@@ -298,6 +341,16 @@ void testNotFoundException() throws Exception {
298341
assertNotNull(errorResponse.getMessage());
299342
}
300343

344+
@Test
345+
void testTooManyRequestsException() throws Exception {
346+
var response = get("/validationTest/tooManyRequests");
347+
assertEquals(HttpStatus.TOO_MANY_REQUESTS, response.getStatusCode());
348+
var errorResponse =
349+
gson.fromJson(response.getBody(), TooManyRequestsException.ClientResponse.class);
350+
assertEquals("tooManyRequests", errorResponse.getType());
351+
assertNotNull(errorResponse.getMessage());
352+
}
353+
301354
@Test
302355
void testDataIntegrityViolationException() throws Exception {
303356
var response = get("/validationTest/dataIntegrityViolation");

src/test/java/no/einnsyn/backend/common/exceptions/ErrorResponseTestController.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
package no.einnsyn.backend.common.exceptions;
22

3+
import jakarta.servlet.http.HttpServletResponse;
34
import jakarta.validation.constraints.Min;
45
import jakarta.validation.constraints.Pattern;
6+
import java.io.IOException;
57
import no.einnsyn.backend.common.exceptions.models.BadRequestException;
68
import no.einnsyn.backend.common.exceptions.models.NotFoundException;
9+
import no.einnsyn.backend.common.exceptions.models.TooManyRequestsException;
710
import org.springframework.context.annotation.Profile;
811
import org.springframework.dao.DataIntegrityViolationException;
912
import org.springframework.http.HttpHeaders;
13+
import org.springframework.http.HttpStatus;
1014
import org.springframework.http.ResponseEntity;
1115
import org.springframework.transaction.TransactionSystemException;
1216
import org.springframework.web.bind.annotation.GetMapping;
@@ -94,6 +98,12 @@ public ResponseEntity<String> testNotFound() throws NotFoundException {
9498
throw new NotFoundException("Simulated not found");
9599
}
96100

101+
/** Endpoint that throws a TooManyRequestsException. */
102+
@GetMapping("/validationTest/tooManyRequests")
103+
public ResponseEntity<String> testTooManyRequests() throws TooManyRequestsException {
104+
throw new TooManyRequestsException("Simulated too many requests");
105+
}
106+
97107
/** Endpoint that throws a DataIntegrityViolationException. */
98108
@GetMapping("/validationTest/dataIntegrityViolation")
99109
public ResponseEntity<String> testDataIntegrityViolation() {
@@ -106,6 +116,18 @@ public ResponseEntity<String> testNoHandlerFound() throws NoHandlerFoundExceptio
106116
throw new NoHandlerFoundException("GET", "/nonexistent", new HttpHeaders());
107117
}
108118

119+
/** Endpoint that sends a configurable error status to exercise the /error controller path. */
120+
@GetMapping("/validationTest/sendError/{statusCode}")
121+
public void testSendError(@PathVariable Integer statusCode, HttpServletResponse response)
122+
throws IOException {
123+
var status = HttpStatus.resolve(statusCode);
124+
if (status == null) {
125+
throw new IllegalArgumentException("Unsupported status code: " + statusCode);
126+
}
127+
128+
response.sendError(statusCode, "Simulated " + status);
129+
}
130+
109131
/**
110132
* Endpoint with @Min constraint with a blank message. When validation fails, the error has a
111133
* blank defaultMessage, triggering the codes fallback in resolveValidationMessage.

0 commit comments

Comments
 (0)