Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/main/java/com/retailsvc/http/Handlers.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import com.retailsvc.http.internal.HealthRenderer;
import com.retailsvc.http.internal.ProblemDetail;
import com.retailsvc.http.internal.ProblemDetailRenderer;
import com.retailsvc.http.internal.ResourceSource;
import java.io.InputStream;
import java.nio.file.Path;
Expand All @@ -27,19 +28,18 @@ public final class Handlers {

private Handlers() {}

public static ExceptionHandler defaultExceptionHandler(TypeMapper jsonMapper) {
Objects.requireNonNull(jsonMapper, "jsonMapper must not be null");
public static ExceptionHandler defaultExceptionHandler() {
return t ->
switch (t) {
case ValidationException ve ->
Response.bytes(
HTTP_BAD_REQUEST,
jsonMapper.writeTo(ProblemDetail.forValidation(ve.error())),
ProblemDetailRenderer.renderJson(ProblemDetail.forValidation(ve.error())),
"application/problem+json");
case BadRequestException bre ->
Response.bytes(
bre.status(),
jsonMapper.writeTo(ProblemDetail.forBadRequest(bre)),
ProblemDetailRenderer.renderJson(ProblemDetail.forBadRequest(bre)),
"application/problem+json");
case NotFoundException _ -> Response.notFound();
case MethodNotAllowedException mna ->
Expand Down
16 changes: 4 additions & 12 deletions src/main/java/com/retailsvc/http/OpenApiServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.retailsvc.http.internal.Router;
import com.retailsvc.http.internal.SecurityFilter;
import com.retailsvc.http.internal.TextTypeMapper;
import com.retailsvc.http.internal.gson.GsonJsonMapper;
import com.retailsvc.http.spec.Operation;
import com.retailsvc.http.spec.Spec;
import com.retailsvc.http.spec.security.SecurityRequirement;
Expand Down Expand Up @@ -47,7 +48,6 @@ public class OpenApiServer implements AutoCloseable {
private static final int DEFAULT_PORT = 8080;
private static final String JSON = "application/json";
private static final String GSON_CLASS = "com.google.gson.Gson";
private static final String GSON_MAPPER_CLASS = "com.retailsvc.http.internal.gson.GsonJsonMapper";

private final HttpServer httpServer;
private final int shutdownTimeoutSeconds;
Expand Down Expand Up @@ -112,8 +112,7 @@ record HandlerConfig(
spec.securitySchemes(),
spec.security(),
handlerConfig.securityValidators(),
handlerConfig.externalAuth(),
bodyMappers.get(JSON)));
handlerConfig.externalAuth()));
ctx.setHandler(
new DispatchHandler(
handlerConfig.handlers(),
Expand Down Expand Up @@ -342,9 +341,7 @@ public OpenApiServer build() throws IOException {
}
Map<String, TypeMapper> resolved = resolveBodyMappers(bodyMappers);
ExceptionHandler effectiveExceptionHandler =
exceptionHandler != null
? exceptionHandler
: Handlers.defaultExceptionHandler(resolved.get(JSON));
exceptionHandler != null ? exceptionHandler : Handlers.defaultExceptionHandler();
HandlerConfig handlerConfig =
new HandlerConfig(
handlers,
Expand Down Expand Up @@ -410,12 +407,7 @@ private static TypeMapper tryLoadGsonMapper() {
} catch (ClassNotFoundException _) {
return null;
}
try {
Class<?> cls = Class.forName(GSON_MAPPER_CLASS, true, OpenApiServer.class.getClassLoader());
return (TypeMapper) cls.getDeclaredConstructor().newInstance();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Failed to load " + GSON_MAPPER_CLASS, e);
}
return new GsonJsonMapper();
}
}
}
26 changes: 1 addition & 25 deletions src/main/java/com/retailsvc/http/internal/HealthRenderer.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,36 +23,12 @@ public static String renderJson(boolean up, List<Dependency> dependencies) {
}
Dependency d = dependencies.get(i);
sb.append("{\"id\":");
appendJsonString(sb, d.id());
JsonStrings.appendQuoted(sb, d.id());
sb.append(",\"status\":\"").append(label(d.up())).append("\"}");
}
return sb.append("]}").toString();
}

private static void appendJsonString(StringBuilder sb, String s) {
sb.append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\b' -> sb.append("\\b");
case '\f' -> sb.append("\\f");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
sb.append('"');
}

private static String label(boolean up) {
return up ? "Up" : "Down";
}
Expand Down
40 changes: 40 additions & 0 deletions src/main/java/com/retailsvc/http/internal/JsonStrings.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.retailsvc.http.internal;

/**
* Minimal JSON string-escape helper shared by the library's hand-rolled JSON writers ({@link
* HealthRenderer}, {@link ProblemDetailRenderer}). Lets those renderers emit RFC 8259 compliant
* strings without pulling in a JSON library and without record-accessor reflection that GraalVM
* Native Image would otherwise need configured.
*/
final class JsonStrings {

/** Codepoints below this value are control characters and must be unicode-escaped. */
private static final int FIRST_PRINTABLE_ASCII = 0x20;

private JsonStrings() {}

/** Appends {@code value} surrounded by double quotes, with JSON string-escaping applied. */
static void appendQuoted(StringBuilder out, String value) {
out.append('"');
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
switch (c) {
case '"' -> out.append("\\\"");
case '\\' -> out.append("\\\\");
case '\b' -> out.append("\\b");
case '\f' -> out.append("\\f");
case '\n' -> out.append("\\n");
case '\r' -> out.append("\\r");
case '\t' -> out.append("\\t");
default -> {
if (c < FIRST_PRINTABLE_ASCII) {
out.append(String.format("\\u%04x", (int) c));
} else {
out.append(c);
}
}
}
}
out.append('"');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.retailsvc.http.internal;

import java.nio.charset.StandardCharsets;

/**
* Built-in JSON writer for the {@code application/problem+json} (RFC 7807) wire shape. Keeps the
* exception and security paths free of any {@code TypeMapper}, so the library can emit problem
* responses without a JSON library on the classpath (and without record-accessor reflection that
* GraalVM Native Image would otherwise need configured).
*
* <p>Null-valued fields are omitted, matching the default Gson encoding the library historically
* produced.
*/
public final class ProblemDetailRenderer {

/** Initial capacity sized for a typical problem-detail document. */
private static final int INITIAL_CAPACITY = 128;

private ProblemDetailRenderer() {}

public static byte[] renderJson(ProblemDetail pd) {
StringBuilder out = new StringBuilder(INITIAL_CAPACITY);
out.append('{');
boolean first = true;
first = appendString(out, first, "type", pd.type());
first = appendString(out, first, "title", pd.title());
first = appendInt(out, first, "status", pd.status());
first = appendString(out, first, "detail", pd.detail());
first = appendString(out, first, "pointer", pd.pointer());
appendString(out, first, "keyword", pd.keyword());
out.append('}');
return out.toString().getBytes(StandardCharsets.UTF_8);
}

private static boolean appendString(StringBuilder out, boolean first, String name, String value) {
if (value == null) {
return first;
}
if (!first) {
out.append(',');
}
out.append('"').append(name).append("\":");
JsonStrings.appendQuoted(out, value);
return false;
}

private static boolean appendInt(StringBuilder out, boolean first, String name, int value) {
if (!first) {
out.append(',');
}
out.append('"').append(name).append("\":").append(value);
return false;
}
}
9 changes: 2 additions & 7 deletions src/main/java/com/retailsvc/http/internal/SecurityFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import com.retailsvc.http.Request;
import com.retailsvc.http.SchemeValidator;
import com.retailsvc.http.TypeMapper;
import com.retailsvc.http.spec.Operation;
import com.retailsvc.http.spec.security.SecurityRequirement;
import com.retailsvc.http.spec.security.SecurityScheme;
Expand All @@ -19,7 +18,6 @@
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

public final class SecurityFilter extends Filter {
Expand All @@ -29,21 +27,18 @@ public final class SecurityFilter extends Filter {
private final List<SecurityRequirement> rootSecurity;
private final Map<String, SchemeValidator> validators;
private final boolean externalAuth;
private final TypeMapper jsonMapper;

public SecurityFilter(
Map<String, Operation> operationsById,
Map<String, SecurityScheme> schemes,
List<SecurityRequirement> rootSecurity,
Map<String, SchemeValidator> validators,
boolean externalAuth,
TypeMapper jsonMapper) {
boolean externalAuth) {
this.operationsById = Map.copyOf(operationsById);
this.schemes = Map.copyOf(schemes);
this.rootSecurity = List.copyOf(rootSecurity);
this.validators = Map.copyOf(validators);
this.externalAuth = externalAuth;
this.jsonMapper = Objects.requireNonNull(jsonMapper, "jsonMapper must not be null");
}

@Override
Expand Down Expand Up @@ -125,7 +120,7 @@ private void renderRejection(HttpExchange exchange, List<GroupOutcome.Failed> fa

ProblemDetail problemDetail =
new ProblemDetail("about:blank", title, status, detail, null, null);
byte[] body = jsonMapper.writeTo(problemDetail);
byte[] body = ProblemDetailRenderer.renderJson(problemDetail);
exchange.getResponseHeaders().add("Content-Type", "application/problem+json");
if (!anyDenied) {
LinkedHashSet<String> attempted = new LinkedHashSet<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class HandlersDefaultExceptionTest {
@Test
void validationExceptionRendersProblemJson() {
Response resp =
Handlers.defaultExceptionHandler(JSON)
Handlers.defaultExceptionHandler()
.handle(
new ValidationException(
new ValidationError("/x", "type", "expected string", null)));
Expand All @@ -35,7 +35,7 @@ void validationExceptionRendersProblemJson() {
@Test
void badRequestExceptionRendersProblemJsonWithCustomStatus() {
Response resp =
Handlers.defaultExceptionHandler(JSON)
Handlers.defaultExceptionHandler()
.handle(new BadRequestException(422, "email taken", "/email", "unique"));

assertThat(resp.status()).isEqualTo(422);
Expand All @@ -53,7 +53,7 @@ void badRequestExceptionRendersProblemJsonWithCustomStatus() {

@Test
void notFoundReturns404() {
Response resp = Handlers.defaultExceptionHandler(JSON).handle(new NotFoundException("GET /x"));
Response resp = Handlers.defaultExceptionHandler().handle(new NotFoundException("GET /x"));

assertThat(resp.status()).isEqualTo(404);
assertThat(resp.body()).isNull();
Expand All @@ -62,7 +62,7 @@ void notFoundReturns404() {
@Test
void methodNotAllowedReturns405WithAllowHeader() {
Response resp =
Handlers.defaultExceptionHandler(JSON)
Handlers.defaultExceptionHandler()
.handle(new MethodNotAllowedException(Set.of(HttpMethod.GET, HttpMethod.POST)));

assertThat(resp.status()).isEqualTo(405);
Expand All @@ -72,7 +72,7 @@ void methodNotAllowedReturns405WithAllowHeader() {

@Test
void unknownExceptionReturns500() {
Response resp = Handlers.defaultExceptionHandler(JSON).handle(new RuntimeException("kaboom"));
Response resp = Handlers.defaultExceptionHandler().handle(new RuntimeException("kaboom"));

assertThat(resp.status()).isEqualTo(500);
assertThat(resp.body()).isNull();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.retailsvc.http.internal;

import static org.assertj.core.api.Assertions.assertThat;

import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;

class ProblemDetailRendererTest {

@Test
void rendersAllFieldsWhenPresent() {
ProblemDetail pd =
new ProblemDetail("about:blank", "Bad Request", 400, "expected string", "/x", "type");
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
.isEqualTo(
"{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400,"
+ "\"detail\":\"expected string\",\"pointer\":\"/x\",\"keyword\":\"type\"}");
}

@Test
void omitsNullPointerAndKeyword() {
ProblemDetail pd =
new ProblemDetail("about:blank", "Unauthorized", 401, "missing token", null, null);
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
.isEqualTo(
"{\"type\":\"about:blank\",\"title\":\"Unauthorized\",\"status\":401,"
+ "\"detail\":\"missing token\"}");
}

@Test
void omitsNullDetail() {
ProblemDetail pd = new ProblemDetail("about:blank", "Not Found", 404, null, null, null);
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
.isEqualTo("{\"type\":\"about:blank\",\"title\":\"Not Found\",\"status\":404}");
}

@Test
void escapesQuoteAndBackslashInDetail() {
ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "a\"b\\c", null, null);
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
.isEqualTo(
"{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400,"
+ "\"detail\":\"a\\\"b\\\\c\"}");
}

@Test
void escapesNamedControlCharsInDetail() {
ProblemDetail pd =
new ProblemDetail("about:blank", "Bad Request", 400, "\b\f\n\r\t", null, null);
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
.isEqualTo(
"{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400,"
+ "\"detail\":\"\\b\\f\\n\\r\\t\"}");
}

@Test
void escapesUnnamedControlCharsAsHexUnicode() {
ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "", null, null);
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
.isEqualTo(
"{\"type\":\"about:blank\",\"title\":\"Bad Request\",\"status\":400,"
+ "\"detail\":\"\\u0001\\u001f\"}");
}

@Test
void passesThroughNonAsciiCharactersVerbatim() {
ProblemDetail pd = new ProblemDetail("about:blank", "Bad Request", 400, "café-é", null, null);
assertThat(asString(ProblemDetailRenderer.renderJson(pd)))
.isEqualTo(
"{\"type\":\"about:blank\",\"title\":\"Bad"
+ " Request\",\"status\":400,\"detail\":\"café-é\"}");
}

private static String asString(byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
}
Loading
Loading