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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 3 additions & 8 deletions src/main/java/com/retailsvc/http/Cors.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -21,8 +18,6 @@
*/
public final class Cors {

private static final String ALLOW = "Allow";

private Cors() {}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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");
Expand Down
21 changes: 6 additions & 15 deletions src/main/java/com/retailsvc/http/Handlers.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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() {}

Expand Down Expand Up @@ -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();
}
};
}
Expand All @@ -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);
};
}

Expand All @@ -121,7 +112,7 @@ public static RequestHandler healthHandler(Supplier<HealthOutcome> 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<Dependency> dependencies;
Expand Down Expand Up @@ -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);
};
}
}
101 changes: 98 additions & 3 deletions src/main/java/com/retailsvc/http/Response.java
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -32,22 +43,83 @@
*/
public record Response(int status, Object body, String contentType, Map<String, String> 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<HttpMethod> 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. */
Expand All @@ -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());
Expand All @@ -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());
Expand Down
Loading
Loading