From 535073cfde11d29b6acafadaf5070d4550a5204e Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Fri, 25 Sep 2026 15:43:51 +0200 Subject: [PATCH] feat: Add Response factories for common statuses Adds ok(), noContent(), notModified(), badRequest(), unauthorized(), forbidden(), conflict(), internalServerError(), methodNotAllowed(...) and JSON-body variants for 400/403/409/422, so handlers no longer need HttpURLConnection constants. Handlers and Cors use them. --- README.md | 15 ++- src/main/java/com/retailsvc/http/Cors.java | 11 +- .../java/com/retailsvc/http/Handlers.java | 21 ++-- .../java/com/retailsvc/http/Response.java | 101 +++++++++++++++++- .../java/com/retailsvc/http/ResponseTest.java | 89 +++++++++++++++ 5 files changed, 208 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index b03cde2..ee27e5b 100644 --- a/README.md +++ b/README.md @@ -108,18 +108,27 @@ public class PostDataHandler implements RequestHandler { `Response` is an immutable record built via static factories. Pick the one that fits: ``` java -Response.empty(); // 204 No Content, no body -Response.status(200); // 200 OK, no body +Response.ok(); // 200 OK, no body Response.ok(Map.of("id", "42")); // 200 OK, JSON body via TypeMapper Response.created(newResource); // 201 Created, JSON body Response.created(newResource) .withHeader("Location", "/things/42"); // 201 Created + Location header Response.accepted(); // 202 Accepted, no body Response.accepted(Map.of("jobId", "job-42")); // 202 Accepted, JSON body +Response.noContent(); // 204 No Content (alias: empty()) +Response.notModified(); // 304 Not Modified +Response.badRequest(problemDetail); // 400 Bad Request, JSON body +Response.unauthorized(); // 401 Unauthorized +Response.forbidden(); // 403 Forbidden Response.notFound(); // 404 Not Found, no body Response.notFound(problemDetail); // 404 Not Found, JSON body +Response.methodNotAllowed(GET, HEAD); // 405 + Allow: GET, HEAD +Response.conflict(conflictDetail); // 409 Conflict, JSON body +Response.unprocessableContent(problemDetail); // 422 Unprocessable Content, JSON body +Response.internalServerError(); // 500 Internal Server Error Response.notImplemented(); // 501 Not Implemented, no body -Response.of(409, conflictDetail); // any status, JSON body +Response.status(418); // any status, no body +Response.of(418, teapot); // any status, JSON body Response.text(200, "hello"); // text/plain; UTF-8 Response.bytes(200, pdf, "application/pdf"); // pre-serialised bytes Response.stream(200, "application/octet-stream", // chunked streaming diff --git a/src/main/java/com/retailsvc/http/Cors.java b/src/main/java/com/retailsvc/http/Cors.java index 4df3eac..e122284 100644 --- a/src/main/java/com/retailsvc/http/Cors.java +++ b/src/main/java/com/retailsvc/http/Cors.java @@ -1,9 +1,6 @@ package com.retailsvc.http; import static com.retailsvc.http.spec.HttpMethod.OPTIONS; -import static java.net.HttpURLConnection.HTTP_BAD_METHOD; -import static java.net.HttpURLConnection.HTTP_FORBIDDEN; -import static java.net.HttpURLConnection.HTTP_NO_CONTENT; import com.retailsvc.http.spec.HttpMethod; import java.time.Duration; @@ -21,8 +18,6 @@ */ public final class Cors { - private static final String ALLOW = "Allow"; - private Cors() {} /** @@ -94,13 +89,13 @@ public static RequestHandler preflightHandler( return req -> { if (req.method() != OPTIONS) { - return Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, "OPTIONS"); + return Response.methodNotAllowed(OPTIONS); } String origin = requireHeader(req, "Origin"); String requestMethod = requireHeader(req, "Access-Control-Request-Method"); if (!isPreflightAllowed( req, origin, requestMethod, originAllowed, allowedMethods, headerAllowlistLower)) { - return Response.status(HTTP_FORBIDDEN); + return Response.forbidden(); } return buildPreflightSuccess( origin, @@ -166,7 +161,7 @@ private static Response buildPreflightSuccess( boolean allowCredentials, String maxAgeHeader) { Response resp = - Response.status(HTTP_NO_CONTENT) + Response.noContent() .withHeader("Access-Control-Allow-Origin", origin) .withHeader("Access-Control-Allow-Methods", allowMethodsHeader) .withHeader("Vary", "Origin"); diff --git a/src/main/java/com/retailsvc/http/Handlers.java b/src/main/java/com/retailsvc/http/Handlers.java index 53f8409..3857d78 100644 --- a/src/main/java/com/retailsvc/http/Handlers.java +++ b/src/main/java/com/retailsvc/http/Handlers.java @@ -2,9 +2,7 @@ import static com.retailsvc.http.spec.HttpMethod.GET; import static com.retailsvc.http.spec.HttpMethod.HEAD; -import static java.net.HttpURLConnection.HTTP_BAD_METHOD; import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; -import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; import static java.net.HttpURLConnection.HTTP_OK; import static java.net.HttpURLConnection.HTTP_UNAVAILABLE; import static java.nio.charset.StandardCharsets.UTF_8; @@ -18,15 +16,12 @@ import java.util.List; import java.util.Objects; import java.util.function.Supplier; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public final class Handlers { private static final Logger LOG = LoggerFactory.getLogger(Handlers.class); - private static final String ALLOW = "Allow"; - private static final String GET_HEAD = "GET, HEAD"; private Handlers() {} @@ -79,14 +74,10 @@ public static ExceptionHandler defaultExceptionHandler() { } yield Response.notFound(); } - case MethodNotAllowedException mna -> - Response.status(HTTP_BAD_METHOD) - .withHeader( - ALLOW, - mna.allowed().stream().map(Enum::name).collect(Collectors.joining(", "))); + case MethodNotAllowedException mna -> Response.methodNotAllowed(mna.allowed()); default -> { LOG.error("Unhandled exception in handler", t); - yield Response.status(HTTP_INTERNAL_ERROR); + yield Response.internalServerError(); } }; } @@ -96,7 +87,7 @@ public static RequestHandler aliveHandler() { return req -> switch (req.method()) { case GET, HEAD -> Response.empty(); - default -> Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, GET_HEAD); + default -> Response.methodNotAllowed(GET, HEAD); }; } @@ -121,7 +112,7 @@ public static RequestHandler healthHandler(Supplier probe) { Objects.requireNonNull(probe, "probe"); return req -> { if (req.method() != GET && req.method() != HEAD) { - return Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, GET_HEAD); + return Response.methodNotAllowed(GET, HEAD); } boolean up; List dependencies; @@ -177,10 +168,10 @@ private static RequestHandler resourceHandler(ResourceSource source) { } }); case HEAD -> - Response.status(HTTP_OK) + Response.ok() .withContentType(contentType) .withHeader("Content-Length", String.valueOf(length)); - default -> Response.status(HTTP_BAD_METHOD).withHeader(ALLOW, GET_HEAD); + default -> Response.methodNotAllowed(GET, HEAD); }; } } diff --git a/src/main/java/com/retailsvc/http/Response.java b/src/main/java/com/retailsvc/http/Response.java index 09ea622..30cbff9 100644 --- a/src/main/java/com/retailsvc/http/Response.java +++ b/src/main/java/com/retailsvc/http/Response.java @@ -1,18 +1,29 @@ package com.retailsvc.http; import static java.net.HttpURLConnection.HTTP_ACCEPTED; +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_CONFLICT; import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED; +import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; import static java.net.HttpURLConnection.HTTP_NO_CONTENT; import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; import com.retailsvc.http.internal.BodyWriter; +import com.retailsvc.http.spec.HttpMethod; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.util.Collection; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * The value returned by every {@link RequestHandler}. Carries status, optional body, optional @@ -32,22 +43,83 @@ */ public record Response(int status, Object body, String contentType, Map headers) { + /** Not defined by {@link java.net.HttpURLConnection}. */ + private static final int HTTP_UNPROCESSABLE_CONTENT = 422; + public Response { headers = headers == null ? Map.of() : Map.copyOf(headers); } // -- one-shot, no-body -- - /** {@code 204 No Content} with no body. */ + /** {@code 204 No Content} with no body. Same as {@link #noContent()}. */ public static Response empty() { - return new Response(HTTP_NO_CONTENT, null, null, Map.of()); + return noContent(); } - /** Given status, no body. Use for {@code 200 OK} no body, {@code 404}, {@code 405}, etc. */ + /** Given status, no body. Prefer a named factory such as {@link #ok()} when one exists. */ public static Response status(int status) { return new Response(status, null, null, Map.of()); } + /** {@code 200 OK} with no body. */ + public static Response ok() { + return status(HTTP_OK); + } + + /** {@code 204 No Content} with no body. */ + public static Response noContent() { + return status(HTTP_NO_CONTENT); + } + + /** {@code 304 Not Modified} with no body. */ + public static Response notModified() { + return status(HTTP_NOT_MODIFIED); + } + + /** {@code 400 Bad Request} with no body. */ + public static Response badRequest() { + return status(HTTP_BAD_REQUEST); + } + + /** {@code 401 Unauthorized} with no body. Add a {@code WWW-Authenticate} header as needed. */ + public static Response unauthorized() { + return status(HTTP_UNAUTHORIZED); + } + + /** {@code 403 Forbidden} with no body. */ + public static Response forbidden() { + return status(HTTP_FORBIDDEN); + } + + /** + * {@code 405 Method Not Allowed} with no body and an {@code Allow} header listing {@code + * allowed}. + */ + public static Response methodNotAllowed(HttpMethod... allowed) { + return methodNotAllowed(List.of(allowed)); + } + + /** + * {@code 405 Method Not Allowed} with no body and an {@code Allow} header listing {@code allowed} + * in {@link HttpMethod} declaration order. + */ + public static Response methodNotAllowed(Collection allowed) { + String allow = + allowed.stream().sorted().distinct().map(Enum::name).collect(Collectors.joining(", ")); + return status(HTTP_BAD_METHOD).withHeader("Allow", allow); + } + + /** {@code 409 Conflict} with no body. */ + public static Response conflict() { + return status(HTTP_CONFLICT); + } + + /** {@code 500 Internal Server Error} with no body. */ + public static Response internalServerError() { + return status(HTTP_INTERNAL_ERROR); + } + // -- one-shot, JSON body -- /** {@code 200 OK} with {@code body} serialised as JSON. */ @@ -73,6 +145,16 @@ public static Response accepted(Object body) { return new Response(HTTP_ACCEPTED, body, null, Map.of()); } + /** {@code 400 Bad Request} with {@code body} serialised as JSON (e.g. a ProblemDetail). */ + public static Response badRequest(Object body) { + return new Response(HTTP_BAD_REQUEST, body, null, Map.of()); + } + + /** {@code 403 Forbidden} with {@code body} serialised as JSON (e.g. a ProblemDetail). */ + public static Response forbidden(Object body) { + return new Response(HTTP_FORBIDDEN, body, null, Map.of()); + } + /** {@code 404 Not Found} with no body. */ public static Response notFound() { return new Response(HTTP_NOT_FOUND, null, null, Map.of()); @@ -83,6 +165,19 @@ public static Response notFound(Object body) { return new Response(HTTP_NOT_FOUND, body, null, Map.of()); } + /** {@code 409 Conflict} with {@code body} serialised as JSON (e.g. a ProblemDetail). */ + public static Response conflict(Object body) { + return new Response(HTTP_CONFLICT, body, null, Map.of()); + } + + /** + * {@code 422 Unprocessable Content} with {@code body} serialised as JSON (e.g. a ProblemDetail). + * Use when the request is well-formed but breaks a business rule. + */ + public static Response unprocessableContent(Object body) { + return new Response(HTTP_UNPROCESSABLE_CONTENT, body, null, Map.of()); + } + /** {@code 501 Not Implemented} with no body. */ public static Response notImplemented() { return new Response(HTTP_NOT_IMPLEMENTED, null, null, Map.of()); diff --git a/src/test/java/com/retailsvc/http/ResponseTest.java b/src/test/java/com/retailsvc/http/ResponseTest.java index d8fd5bd..79d35bb 100644 --- a/src/test/java/com/retailsvc/http/ResponseTest.java +++ b/src/test/java/com/retailsvc/http/ResponseTest.java @@ -1,16 +1,37 @@ package com.retailsvc.http; +import static com.retailsvc.http.spec.HttpMethod.GET; +import static com.retailsvc.http.spec.HttpMethod.HEAD; +import static com.retailsvc.http.spec.HttpMethod.POST; import static java.net.HttpURLConnection.HTTP_ACCEPTED; +import static java.net.HttpURLConnection.HTTP_BAD_METHOD; +import static java.net.HttpURLConnection.HTTP_BAD_REQUEST; +import static java.net.HttpURLConnection.HTTP_CONFLICT; import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_FORBIDDEN; +import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; import static java.net.HttpURLConnection.HTTP_NOT_FOUND; import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED; +import static java.net.HttpURLConnection.HTTP_NOT_MODIFIED; +import static java.net.HttpURLConnection.HTTP_NO_CONTENT; +import static java.net.HttpURLConnection.HTTP_OK; +import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED; import static org.assertj.core.api.Assertions.assertThat; import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; class ResponseTest { + private static final int HTTP_UNPROCESSABLE_CONTENT = 422; + @Test void acceptedNoBody() { Response r = Response.accepted(); @@ -79,4 +100,72 @@ void notImplementedNoBody() { assertThat(r.status()).isEqualTo(HTTP_NOT_IMPLEMENTED); assertThat(r.body()).isNull(); } + + static Stream noBodyFactories() { + return Stream.of( + Arguments.of((Supplier) Response::ok, HTTP_OK), + Arguments.of((Supplier) Response::noContent, HTTP_NO_CONTENT), + Arguments.of((Supplier) Response::empty, HTTP_NO_CONTENT), + Arguments.of((Supplier) Response::notModified, HTTP_NOT_MODIFIED), + Arguments.of((Supplier) Response::badRequest, HTTP_BAD_REQUEST), + Arguments.of((Supplier) Response::unauthorized, HTTP_UNAUTHORIZED), + Arguments.of((Supplier) Response::forbidden, HTTP_FORBIDDEN), + Arguments.of((Supplier) Response::conflict, HTTP_CONFLICT), + Arguments.of((Supplier) Response::internalServerError, HTTP_INTERNAL_ERROR)); + } + + @ParameterizedTest + @MethodSource("noBodyFactories") + void noBodyFactoryHasStatusAndNoBody(Supplier factory, int status) { + Response r = factory.get(); + + assertThat(r.status()).isEqualTo(status); + assertThat(r.body()).isNull(); + assertThat(r.contentType()).isNull(); + assertThat(r.headers()).isEmpty(); + } + + static Stream bodyFactories() { + return Stream.of( + Arguments.of((Function) Response::badRequest, HTTP_BAD_REQUEST), + Arguments.of((Function) Response::forbidden, HTTP_FORBIDDEN), + Arguments.of((Function) Response::conflict, HTTP_CONFLICT), + Arguments.of( + (Function) Response::unprocessableContent, + HTTP_UNPROCESSABLE_CONTENT)); + } + + @ParameterizedTest + @MethodSource("bodyFactories") + void bodyFactoryHasStatusAndBody(Function factory, int status) { + Map problem = Map.of("title", "Nope"); + Response r = factory.apply(problem); + + assertThat(r.status()).isEqualTo(status); + assertThat(r.body()).isEqualTo(problem); + assertThat(r.headers()).isEmpty(); + } + + @Test + void methodNotAllowedListsMethodsInAllowHeader() { + Response r = Response.methodNotAllowed(GET, HEAD); + + assertThat(r.status()).isEqualTo(HTTP_BAD_METHOD); + assertThat(r.body()).isNull(); + assertThat(r.headers()).containsExactly(Map.entry("Allow", "GET, HEAD")); + } + + @Test + void methodNotAllowedOrdersAndDeduplicatesMethods() { + Response r = Response.methodNotAllowed(HEAD, POST, GET, HEAD); + + assertThat(r.headers()).containsEntry("Allow", "GET, POST, HEAD"); + } + + @Test + void methodNotAllowedAcceptsCollection() { + Response r = Response.methodNotAllowed(Set.of(POST, GET)); + + assertThat(r.headers()).containsEntry("Allow", "GET, POST"); + } }