Created
October 11, 2025 08:59
-
-
Save stefanofago73/9889b7672fd1f532263417d4e522567d to your computer and use it in GitHub Desktop.
RFC 9457 HTTP Problem Details - Minimal Helper for Spring Boot 2.7.x & C - No dependencies, no libs or frameworks, pure Spring!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import java.net.URI; | |
| import java.util.LinkedHashMap; | |
| import java.util.Map; | |
| import java.util.Objects; | |
| import org.springframework.http.HttpHeaders; | |
| import org.springframework.http.HttpStatus; | |
| import org.springframework.http.MediaType; | |
| import org.springframework.http.ResponseEntity; | |
| /** | |
| * | |
| * Minimal RFC 9457 Problem Details POJO (works on Spring Boot 2.7.x). | |
| * Uses modern Java (records, var, pattern matching) where helpful while | |
| * keeping Jackson-friendly getters. | |
| * | |
| */ | |
| public final class Problems { | |
| private Problems() {} | |
| // RFC constants - Reasonable default for Type when not specified | |
| public static final URI ABOUT_BLANK = URI.create("about:blank"); | |
| // RFC constants - Reasonable default for Media.Type/Mime-Type when not specified | |
| public static final MediaType PROBLEM_JSON = new MediaType("application", "problem+json"); | |
| /** | |
| * | |
| * Core data holder for problem details (kept mutable + getters for Jackson on Boot 2.7). | |
| * | |
| * WARN: the extensions are put under the "extensions" void during the JSON serialization | |
| * For this reason a "flattening action" is done with asMap() method | |
| * | |
| */ | |
| public static final class ProblemDetail { | |
| private URI type = ABOUT_BLANK; | |
| private String title; | |
| // is an advisory field DO NOT USE FOR SECURITY; | |
| // must match HTTP status code used | |
| private int status; | |
| private String detail; | |
| private URI instance; | |
| private Map<String, Object> properties; | |
| public URI getType() { return type; } | |
| public String getTitle() { return title; } | |
| public int getStatus() { return status; } | |
| public String getDetail() { return detail; } | |
| public URI getInstance() { return instance; } | |
| public Map<String, Object> getExtensions() { return properties; } | |
| void setType(URI type) { this.type = Objects.requireNonNull(type, "'type' is required"); } | |
| void setTitle(String title) { this.title = title; } | |
| void setStatus(HttpStatus status) { this.status = status.value(); } | |
| void setStatus(int status) { this.status = status; } | |
| void setDetail(String detail) { this.detail = detail; } | |
| void setInstance(URI instance) { this.instance = instance; } | |
| void setExtension(String name, Object value) { | |
| if (properties == null) properties = new LinkedHashMap<>(); | |
| properties.put(name, value); | |
| } | |
| public final Map<String,Object> asMap(){ | |
| var result = new LinkedHashMap<String, Object>(); | |
| result.put("type", getType()); | |
| result.put("title", getTitle()); | |
| result.put("status", getStatus()); | |
| result.put("instance", getInstance()); | |
| result.put("detail", getDetail()); | |
| for(Map.Entry<String, Object> entry: properties.entrySet()) { | |
| result.put(entry.getKey(), entry.getValue()); | |
| } | |
| return result; | |
| } | |
| } | |
| // ---------- DSL entry points | |
| /** | |
| * | |
| * BLANK case (only HTTP status) -> type=about:blank, title will default to reason phrase. | |
| * | |
| * Even if status isn’t mandatory per RFC, we make it explicit here as requested | |
| * | |
| */ | |
| public static Builder of(HttpStatus status) { | |
| var pd = new ProblemDetail(); | |
| pd.setStatus(status); | |
| pd.setType(ABOUT_BLANK); // RFC default when type omitted; we set it explicitly | |
| pd.setTitle(status.getReasonPhrase()); | |
| return new Builder(pd); | |
| } | |
| /** | |
| * TYPED case (you provide a type URI and title) -> not about:blank. | |
| * Status still required to align the HTTP response and the advisory body. | |
| */ | |
| public static Builder of(HttpStatus status, URI type, String title) { | |
| var pd = new ProblemDetail(); | |
| pd.setStatus(status); | |
| pd.setType(Objects.requireNonNull(type)); | |
| pd.setTitle(Objects.requireNonNull(title)); | |
| return new Builder(pd); | |
| } | |
| // ---------- The "Fluent Interface" | |
| public static final class Builder { | |
| private final ProblemDetail pd; | |
| private final HttpHeaders headers = new HttpHeaders(); | |
| private Builder(ProblemDetail pd) { | |
| this.pd = pd; | |
| headers.setContentType(PROBLEM_JSON); // RFC 9457 media type | |
| } | |
| public Builder detail(String detail) { | |
| pd.setDetail(detail); | |
| return this; | |
| } | |
| public Builder instance(URI instance) { | |
| pd.setInstance(instance); | |
| return this; | |
| } | |
| public Builder extension(String name, Object value) { | |
| pd.setExtension(name, value); | |
| return this; | |
| } | |
| /** | |
| * | |
| * Build while preserving your generic-covariance hack. | |
| * Returns ResponseEntity<T> via raw cast to avoid generic | |
| * inference issues in controllers. Transform to a map better | |
| * representing the body object. | |
| * | |
| * @param <T> | |
| * @return | |
| */ | |
| public <T> ResponseEntity<T> toEntityAsMap() { | |
| @SuppressWarnings({ "rawtypes", "unchecked" }) | |
| ResponseEntity re = new ResponseEntity(pd.asMap(), headers, HttpStatus.valueOf(pd.getStatus())); | |
| @SuppressWarnings("unchecked") | |
| var result = (ResponseEntity<T>) re; | |
| return result; | |
| } | |
| /** | |
| * | |
| * Build while preserving your generic-covariance hack. | |
| * Returns ResponseEntity<T> via raw cast to avoid generic | |
| * inference issues in controllers. Transform to a map better | |
| * representing the body object. | |
| * | |
| * This version make possible to alter default HTTP Headers: this is important | |
| * for Media-Type, Content-Type, Content-Lenght, Content-Language and other negotiation | |
| * elements. | |
| * | |
| * @param <T> | |
| * @param headers | |
| * @return | |
| */ | |
| public <T> ResponseEntity<T> toEntityAsMap(HttpHeaders headers) { | |
| @SuppressWarnings({ "rawtypes", "unchecked" }) | |
| ResponseEntity re = new ResponseEntity(pd.asMap(), headers, HttpStatus.valueOf(pd.getStatus())); | |
| @SuppressWarnings("unchecked") | |
| var result = (ResponseEntity<T>) re; | |
| return result; | |
| } | |
| /** | |
| * | |
| * Build while preserving your generic-covariance hack. | |
| * Returns ResponseEntity<T> via raw cast to avoid generic | |
| * inference issues in controllers. | |
| * | |
| */ | |
| public <T> ResponseEntity<T> toEntity() { | |
| @SuppressWarnings({ "rawtypes", "unchecked" }) | |
| ResponseEntity re = new ResponseEntity(pd, headers, HttpStatus.valueOf(pd.getStatus())); | |
| @SuppressWarnings("unchecked") | |
| var result = (ResponseEntity<T>) re; | |
| return result; | |
| } | |
| /** | |
| * | |
| * Build while preserving your generic-covariance hack. | |
| * Returns ResponseEntity<T> via raw cast to avoid generic | |
| * inference issues in controllers. | |
| * | |
| * This version make possible to alter default HTTP Headers: this is important | |
| * for Media-Type, Content-Type, Content-Lenght, Content-Language and other negotiation | |
| * elements. | |
| * | |
| * @param <T> | |
| * @param headers | |
| * @return | |
| * | |
| */ | |
| public <T> ResponseEntity<T> toEntity(HttpHeaders headers) { | |
| @SuppressWarnings({ "rawtypes", "unchecked" }) | |
| ResponseEntity re = new ResponseEntity(pd, headers, HttpStatus.valueOf(pd.getStatus())); | |
| @SuppressWarnings("unchecked") | |
| var result = (ResponseEntity<T>) re; | |
| return result; | |
| } | |
| } | |
| } | |
| public class MyClass { | |
| public static void main(String args[]) { | |
| // | |
| // Problems.of(HttpStatus.BAD_REQUEST).toEntity(); | |
| // | |
| // | |
| // Problems.of(HttpStatus.FORBIDDEN, | |
| // URI.create("http://www.example.com/access/errors/forbidden-access"), | |
| // "User with not valid grants!") | |
| // .detail("The User KaBoom can have the right to do This!") | |
| // .instance(URI.create("/api/hello")) | |
| // .extension("my_extension", UUID.randomUUID()) | |
| // .toEntity(); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment