Skip to content

Commit 926c1a5

Browse files
committed
feat(http): RFC 7807 problem+json renderer + default handler covers new types
1 parent 10a6d08 commit 926c1a5

5 files changed

Lines changed: 162 additions & 18 deletions

File tree

src/main/java/com/retailsvc/http/ExceptionHandler.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
*
1010
* @author thced
1111
*/
12+
@FunctionalInterface
1213
public interface ExceptionHandler {
1314

14-
void handleException(HttpExchange exchange, Exception e) throws IOException;
15+
void handle(HttpExchange exchange, Throwable t) throws IOException;
16+
17+
default void handleException(HttpExchange exchange, Exception e) throws IOException {
18+
handle(exchange, e);
19+
}
1520
}
Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,56 @@
11
package com.retailsvc.http;
22

3+
import static java.net.HttpURLConnection.HTTP_BAD_METHOD;
4+
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
35
import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;
46
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
7+
import static java.nio.charset.StandardCharsets.UTF_8;
58

6-
import com.sun.net.httpserver.HttpExchange;
9+
import com.retailsvc.http.internal.ProblemDetailRenderer;
710
import com.sun.net.httpserver.HttpHandler;
811
import java.io.IOException;
12+
import java.util.stream.Collectors;
913
import org.slf4j.Logger;
1014
import org.slf4j.LoggerFactory;
1115

12-
public class Handlers {
16+
public final class Handlers {
1317

1418
private static final Logger LOG = LoggerFactory.getLogger(Handlers.class);
1519

1620
private Handlers() {}
1721

18-
public static HttpHandler notFoundHandler() {
19-
return exchange -> {
22+
public static ExceptionHandler defaultExceptionHandler() {
23+
return (exchange, t) -> {
2024
try (exchange) {
21-
endRequest(exchange, HTTP_NOT_FOUND);
25+
switch (t) {
26+
case ValidationException ve -> {
27+
byte[] body = ProblemDetailRenderer.render(ve.error()).getBytes(UTF_8);
28+
exchange.getResponseHeaders().add("Content-Type", "application/problem+json");
29+
exchange.sendResponseHeaders(HTTP_BAD_REQUEST, body.length);
30+
exchange.getResponseBody().write(body);
31+
}
32+
case NotFoundException nf -> exchange.sendResponseHeaders(HTTP_NOT_FOUND, 0);
33+
case MethodNotAllowedException mna -> {
34+
String allow = mna.allowed().stream().map(Enum::name).collect(Collectors.joining(", "));
35+
exchange.getResponseHeaders().add("Allow", allow);
36+
exchange.sendResponseHeaders(HTTP_BAD_METHOD, 0);
37+
}
38+
default -> {
39+
LOG.error("Unhandled exception in handler", t);
40+
exchange.sendResponseHeaders(HTTP_INTERNAL_ERROR, 0);
41+
}
42+
}
43+
} catch (IOException io) {
44+
LOG.error("Failed writing error response", io);
2245
}
2346
};
2447
}
2548

26-
public static ExceptionHandler internalServerErrorHandler() {
27-
return (exchange, throwable) -> {
49+
public static HttpHandler notFoundHandler() {
50+
return exchange -> {
2851
try (exchange) {
29-
LOG.error("Error in handling request", throwable);
30-
endRequest(exchange, HTTP_INTERNAL_ERROR);
52+
exchange.sendResponseHeaders(HTTP_NOT_FOUND, 0);
3153
}
3254
};
3355
}
34-
35-
public static ExceptionHandler defaultExceptionHandler() {
36-
return (exchange, e) -> internalServerErrorHandler().handleException(exchange, e);
37-
}
38-
39-
private static void endRequest(HttpExchange exchange, int status) throws IOException {
40-
exchange.sendResponseHeaders(status, 0);
41-
}
4256
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.retailsvc.http.internal;
2+
3+
import com.retailsvc.http.validate.ValidationError;
4+
5+
public final class ProblemDetailRenderer {
6+
private ProblemDetailRenderer() {}
7+
8+
public static String render(ValidationError error) {
9+
return "{"
10+
+ "\"type\":\"about:blank\","
11+
+ "\"title\":\"Bad Request\","
12+
+ "\"status\":400,"
13+
+ "\"detail\":\""
14+
+ escape(error.message())
15+
+ "\","
16+
+ "\"pointer\":\""
17+
+ escape(error.pointer())
18+
+ "\","
19+
+ "\"keyword\":\""
20+
+ escape(error.keyword())
21+
+ "\""
22+
+ "}";
23+
}
24+
25+
private static String escape(String s) {
26+
StringBuilder b = new StringBuilder(s.length() + 8);
27+
for (int i = 0; i < s.length(); i++) {
28+
char c = s.charAt(i);
29+
switch (c) {
30+
case '\\' -> b.append("\\\\");
31+
case '"' -> b.append("\\\"");
32+
case '\n' -> b.append("\\n");
33+
case '\r' -> b.append("\\r");
34+
case '\t' -> b.append("\\t");
35+
default -> {
36+
if (c < 0x20) b.append(String.format("\\u%04x", (int) c));
37+
else b.append(c);
38+
}
39+
}
40+
}
41+
return b.toString();
42+
}
43+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.retailsvc.http;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.retailsvc.http.spec.HttpMethod;
6+
import com.retailsvc.http.validate.ValidationError;
7+
import com.sun.net.httpserver.Headers;
8+
import com.sun.net.httpserver.HttpExchange;
9+
import java.io.ByteArrayOutputStream;
10+
import java.util.Set;
11+
import org.junit.jupiter.api.Test;
12+
import org.mockito.Mockito;
13+
14+
class HandlersDefaultExceptionTest {
15+
private HttpExchange newExchange(ByteArrayOutputStream sink) {
16+
HttpExchange ex = Mockito.mock(HttpExchange.class);
17+
Mockito.when(ex.getResponseHeaders()).thenReturn(new Headers());
18+
Mockito.when(ex.getResponseBody()).thenReturn(sink);
19+
return ex;
20+
}
21+
22+
@Test
23+
void validationExceptionRendersProblem() throws Exception {
24+
ByteArrayOutputStream sink = new ByteArrayOutputStream();
25+
HttpExchange ex = newExchange(sink);
26+
27+
Handlers.defaultExceptionHandler()
28+
.handle(
29+
ex,
30+
new ValidationException(new ValidationError("/x", "type", "expected string", null)));
31+
32+
Mockito.verify(ex).sendResponseHeaders(Mockito.eq(400), Mockito.anyLong());
33+
assertThat(ex.getResponseHeaders().getFirst("Content-Type"))
34+
.isEqualTo("application/problem+json");
35+
assertThat(sink.toString()).contains("\"keyword\":\"type\"");
36+
}
37+
38+
@Test
39+
void notFoundReturns404() throws Exception {
40+
HttpExchange ex = newExchange(new ByteArrayOutputStream());
41+
Handlers.defaultExceptionHandler().handle(ex, new NotFoundException("GET /x"));
42+
Mockito.verify(ex).sendResponseHeaders(404, 0);
43+
}
44+
45+
@Test
46+
void methodNotAllowedReturns405WithAllowHeader() throws Exception {
47+
HttpExchange ex = newExchange(new ByteArrayOutputStream());
48+
Handlers.defaultExceptionHandler()
49+
.handle(ex, new MethodNotAllowedException(Set.of(HttpMethod.GET, HttpMethod.POST)));
50+
Mockito.verify(ex).sendResponseHeaders(405, 0);
51+
assertThat(ex.getResponseHeaders().getFirst("Allow")).contains("GET").contains("POST");
52+
}
53+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import com.retailsvc.http.validate.ValidationError;
6+
import org.junit.jupiter.api.Test;
7+
8+
class ProblemDetailRendererTest {
9+
@Test
10+
void rendersExpectedFields() {
11+
String body =
12+
ProblemDetailRenderer.render(
13+
new ValidationError("/email", "format", "string does not match format 'email'", null));
14+
assertThat(body)
15+
.contains("\"type\":\"about:blank\"")
16+
.contains("\"title\":\"Bad Request\"")
17+
.contains("\"status\":400")
18+
.contains("\"pointer\":\"/email\"")
19+
.contains("\"keyword\":\"format\"")
20+
.contains("\"detail\":\"string does not match format 'email'\"");
21+
}
22+
23+
@Test
24+
void escapesQuotesInDetail() {
25+
String body =
26+
ProblemDetailRenderer.render(new ValidationError("/x", "k", "has \"quotes\"", null));
27+
assertThat(body).contains("\"detail\":\"has \\\"quotes\\\"\"");
28+
}
29+
}

0 commit comments

Comments
 (0)