Skip to content

Instantly share code, notes, and snippets.

@stefanofago73
Created October 11, 2025 08:59
Show Gist options
  • Select an option

  • Save stefanofago73/9889b7672fd1f532263417d4e522567d to your computer and use it in GitHub Desktop.

Select an option

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!
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