Skip to content
Closed
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
13 changes: 13 additions & 0 deletions src/main/java/com/retailsvc/http/AfterResponseHook.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,18 @@
@FunctionalInterface
public interface AfterResponseHook {

/**
* Invoked after the response has been written to the client.
*
* <p>Any exception thrown by this method is logged at DEBUG and swallowed; it does not affect the
* response (already sent) and does not prevent subsequent hooks from running.
*
* @param request the resolved {@link Request} that was handled; routing and parameter/body
* validation have already passed by the time this is called
* @param response the {@link Response} that was written to the client. {@link Response#status()}
* and {@link Response#headers()} reflect what was sent on the wire; {@link Response#body()}
* may be {@code null} on streaming responses (and is always {@code null} on the error path,
* since the body bytes have already been emitted)
*/
void after(Request request, Response response);
}
58 changes: 58 additions & 0 deletions src/main/java/com/retailsvc/http/BadRequestException.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,58 @@ public final class BadRequestException extends RuntimeException {

private static final int DEFAULT_STATUS = 400;

/** HTTP 4xx status code carried by this exception. */
private final int status;

/** JSON Pointer (RFC 6901) to the offending property; may be {@code null}. */
private final String pointer;

/** Validation keyword that failed (for example {@code "required"}); may be {@code null}. */
private final String keyword;

/**
* Creates a new exception with the default HTTP status {@code 400 Bad Request}.
*
* <p>Equivalent to {@link #BadRequestException(int, String, String, String)} invoked with {@code
* status = 400} and no pointer or keyword.
*
* @param detail human-readable explanation of the error; surfaced as the RFC 7807 {@code detail}
* member
*/
public BadRequestException(String detail) {
this(DEFAULT_STATUS, detail, null, null);
}

/**
* Creates a new exception with an explicit 4xx HTTP status.
*
* <p>Equivalent to {@link #BadRequestException(int, String, String, String)} with no pointer or
* keyword.
*
* @param status the HTTP status code; must be in the range {@code 400}-{@code 499} or an {@link
* IllegalArgumentException} is thrown
* @param detail human-readable explanation of the error; surfaced as the RFC 7807 {@code detail}
* member
*/
public BadRequestException(int status, String detail) {
this(status, detail, null, null);
}

/**
* Creates a new exception with the full set of RFC 7807 problem fields. Intended for
* validation-style errors where the offending property can be pinpointed with a JSON Pointer and
* the failing rule identified by a JSON Schema keyword.
*
* @param status the HTTP status code; must be in the range {@code 400}-{@code 499}
* @param detail human-readable explanation of the error; surfaced as the RFC 7807 {@code detail}
* member
* @param pointer JSON Pointer (RFC 6901) to the offending property in the request payload; may be
* {@code null} when not applicable
* @param keyword JSON Schema / validation keyword that failed (for example {@code "required"},
* {@code "pattern"}, {@code "maxLength"}); may be {@code null} when not applicable
* @throws NullPointerException if {@code detail} is {@code null}
* @throws IllegalArgumentException if {@code status} is not in the range {@code 400}-{@code 499}
*/
public BadRequestException(int status, String detail, String pointer, String keyword) {
super(Objects.requireNonNull(detail, "detail must not be null"));
if (status < 400 || status > 499) {
Expand All @@ -38,14 +78,32 @@ public BadRequestException(int status, String detail, String pointer, String key
this.keyword = keyword;
}

/**
* Returns the HTTP status code carried by this exception.
*
* @return the HTTP status code; always a 4xx value in the range {@code 400}-{@code 499}
*/
public int status() {
return status;
}

/**
* Returns the JSON Pointer locating the offending property in the request payload.
*
* @return an {@link Optional} containing the JSON Pointer (RFC 6901) to the offending property,
* or {@link Optional#empty()} when no pointer was supplied
*/
public Optional<String> pointer() {
return Optional.ofNullable(pointer);
}

/**
* Returns the JSON Schema / validation keyword that failed.
*
* @return an {@link Optional} containing the failing JSON Schema or validation keyword (for
* example {@code "required"}, {@code "pattern"}), or {@link Optional#empty()} when no keyword
* was supplied
*/
public Optional<String> keyword() {
return Optional.ofNullable(keyword);
}
Expand Down
16 changes: 16 additions & 0 deletions src/main/java/com/retailsvc/http/Credential.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,25 @@
public sealed interface Credential
permits Credential.ApiKeyCredential, Credential.BearerCredential, Credential.BasicCredential {

/**
* Credential extracted from an OpenAPI {@code apiKey} security scheme (header, query, or cookie).
*
* @param value raw key value as presented by the client.
*/
record ApiKeyCredential(String value) implements Credential {}

/**
* Credential extracted from an {@code http} security scheme with {@code bearer} style.
*
* @param token raw bearer token (no {@code Bearer } prefix).
*/
record BearerCredential(String token) implements Credential {}

/**
* Credential extracted from an {@code http} security scheme with {@code basic} style.
*
* @param username decoded username portion.
* @param password decoded password portion.
*/
record BasicCredential(String username, String password) implements Credential {}
}
5 changes: 5 additions & 0 deletions src/main/java/com/retailsvc/http/Dependency.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
* @param up whether the dependency is healthy
*/
public record Dependency(String id, boolean up) {
/**
* Canonical constructor that validates {@code id} is non-null.
*
* @throws NullPointerException if {@code id} is {@code null}
*/
public Dependency {
Objects.requireNonNull(id, "id");
}
Expand Down
8 changes: 8 additions & 0 deletions src/main/java/com/retailsvc/http/ExceptionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,13 @@
@FunctionalInterface
public interface ExceptionHandler {

/**
* Maps the throwable to the {@link Response} to render.
*
* @param t the exception thrown anywhere in the request pipeline (never null)
* @return the response to write; library default maps {@code BadRequestException} to 4xx
* problem+json and anything else to 500 problem+json with the exception message redacted by
* default
*/
Response handle(Throwable t);
}
3 changes: 3 additions & 0 deletions src/main/java/com/retailsvc/http/GsonTypeMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public GsonTypeMapper() {
/**
* Creates a mapper backed by the supplied {@link Gson}.
*
* @param gson the {@link Gson} instance to delegate to
* @throws NullPointerException if {@code gson} is null
*/
public GsonTypeMapper(Gson gson) {
Expand All @@ -47,6 +48,8 @@ public GsonTypeMapper(Gson gson) {
* Returns a {@link GsonBuilder} pre-populated with the wrapped {@link Gson}'s configuration, so
* callers can derive a customized {@link Gson} from the library default (or from their own
* starting point).
*
* @return a new {@link GsonBuilder} seeded with the wrapped {@link Gson}'s configuration
*/
public GsonBuilder gsonBuilder() {
return delegate.gson().newBuilder();
Expand Down
20 changes: 19 additions & 1 deletion src/main/java/com/retailsvc/http/Handlers.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,20 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/** Built-in {@link RequestHandler} and {@link ExceptionHandler} factories. */
public final class Handlers {

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

private Handlers() {}

/**
* Default {@link ExceptionHandler} that maps common library exceptions to RFC 7807 problem
* responses and falls back to a 500 for anything unrecognised.
*
* @param jsonMapper mapper used to serialise problem detail bodies
* @return the exception handler
*/
public static ExceptionHandler defaultExceptionHandler(TypeMapper jsonMapper) {
Objects.requireNonNull(jsonMapper, "jsonMapper must not be null");
return t ->
Expand Down Expand Up @@ -54,7 +62,12 @@ public static ExceptionHandler defaultExceptionHandler(TypeMapper jsonMapper) {
};
}

/** Returns 204 No Content on GET/HEAD; 405 with {@code Allow: GET, HEAD} otherwise. */
/**
* Liveness handler that returns 204 No Content on GET/HEAD; 405 with {@code Allow: GET, HEAD}
* otherwise.
*
* @return the liveness handler
*/
public static RequestHandler aliveHandler() {
return req ->
switch (req.method()) {
Expand All @@ -79,6 +92,7 @@ public static RequestHandler aliveHandler() {
* <p>The body is rendered by a built-in writer; no JSON library on the classpath is required.
*
* @param probe supplier of the current {@link HealthOutcome}
* @return the health handler
*/
public static RequestHandler healthHandler(Supplier<HealthOutcome> probe) {
Objects.requireNonNull(probe, "probe");
Expand Down Expand Up @@ -110,6 +124,7 @@ public static RequestHandler healthHandler(Supplier<HealthOutcome> probe) {
* request — the handler owns the stream lifecycle.
*
* @param classpathResource absolute classpath path, e.g. {@code /schemas/v1/openapi.yaml}
* @return the spec handler
*/
public static RequestHandler resourceHandler(String classpathResource) {
return resourceHandler(ResourceSource.ofClasspath(classpathResource));
Expand All @@ -119,6 +134,9 @@ public static RequestHandler resourceHandler(String classpathResource) {
* Serves a filesystem file as a streaming response. Content-Type is inferred from the file
* extension. Existence and length are resolved at construction; a missing file fails immediately
* with {@link IllegalArgumentException}. The file is opened and closed per request.
*
* @param file path to the file to serve
* @return a handler that streams {@code file} on {@code GET}
*/
public static RequestHandler resourceHandler(Path file) {
return resourceHandler(ResourceSource.ofFile(file));
Expand Down
7 changes: 6 additions & 1 deletion src/main/java/com/retailsvc/http/HealthOutcome.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,16 @@
*/
public record HealthOutcome(List<Dependency> dependencies) {

/** Canonical constructor; normalises a {@code null} dependency list to an empty list. */
public HealthOutcome {
dependencies = List.copyOf(Objects.requireNonNullElse(dependencies, List.of()));
}

/** {@code true} when every dependency is up (vacuously true for an empty list). */
/**
* Reports the aggregate health derived from {@link #dependencies()}.
*
* @return {@code true} when every dependency is up (vacuously true for an empty list)
*/
public boolean up() {
return dependencies.stream().allMatch(Dependency::up);
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/retailsvc/http/Jackson2JsonTypeMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ public final class Jackson2JsonTypeMapper implements TypedTypeMapper {

private final ObjectMapper mapper;

/**
* Creates a new mapper backed by the given Jackson {@link ObjectMapper}.
*
* @param mapper the fully-configured Jackson mapper to delegate to; must not be {@code null}
*/
public Jackson2JsonTypeMapper(ObjectMapper mapper) {
this.mapper = Objects.requireNonNull(mapper, "mapper must not be null");
}
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/retailsvc/http/Jackson3JsonTypeMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ public final class Jackson3JsonTypeMapper implements TypedTypeMapper {

private final ObjectMapper mapper;

/**
* Creates a new mapper backed by the given Jackson 3 {@link ObjectMapper}.
*
* @param mapper the fully-configured Jackson 3 mapper to delegate to; must not be {@code null}
*/
public Jackson3JsonTypeMapper(ObjectMapper mapper) {
this.mapper = Objects.requireNonNull(mapper, "mapper must not be null");
}
Expand Down
12 changes: 12 additions & 0 deletions src/main/java/com/retailsvc/http/MethodNotAllowedException.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,26 @@
import com.retailsvc.http.spec.HttpMethod;
import java.util.Set;

/** Thrown when a request targets a known path with an HTTP method that is not declared for it. */
public final class MethodNotAllowedException extends RuntimeException {
/** The set of HTTP methods that the matched path accepts. */
private final Set<HttpMethod> allowed;

/**
* Creates a new exception carrying the methods the path actually accepts.
*
* @param allowed methods declared for the matched path
*/
public MethodNotAllowedException(Set<HttpMethod> allowed) {
super("method not allowed; allowed=" + allowed);
this.allowed = Set.copyOf(allowed);
}

/**
* Returns the HTTP methods allowed for the matched path.
*
* @return immutable set of allowed methods
*/
public Set<HttpMethod> allowed() {
return allowed;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package com.retailsvc.http;

/** Thrown when a request resolves to an OpenAPI operationId that has no registered handler. */
public final class MissingOperationHandlerException extends RuntimeException {
/**
* Creates a new exception identifying the unregistered operation.
*
* @param operationId the OpenAPI operationId with no handler bound
*/
public MissingOperationHandlerException(String operationId) {
super("no handler registered for operationId=" + operationId);
}
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/retailsvc/http/NotFoundException.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package com.retailsvc.http;

/** Thrown when a request path does not match any route declared in the OpenAPI spec. */
public final class NotFoundException extends RuntimeException {
/**
* Creates a new exception with the given detail message.
*
* @param message human-readable explanation of the missing route
*/
public NotFoundException(String message) {
super(message);
}
Expand Down
Loading