Core Concepts
Response Writing
Soklet's ResponseMarshaler is responsible for preparing finalized response data to be sent to the client over the wire - it might be the result of a Resource Method that executed successfully which you'd like to marshal to JSON, a friendly representation of an uncaught exception that bubbled out, a custom response for an HTTP 405 Method Not Allowed, and so forth.
This approach enforces a clear separation between your business logic and the details of how data is transformed and written back to clients, which simplifies automated testing and hews to the Single Responsibility Principle.
For example, if your Resource Method looks like this:
@POST("/reverse")
public List<Integer> reverse(@RequestBody List<Integer> numbers) {
return numbers.reversed();
}
@POST("/reverse")
public List<Integer> reverse(@RequestBody List<Integer> numbers) {
return numbers.reversed();
}
You might want clients to receive a JSON response body like this:
[3,2,1]
[3,2,1]
Your ResponseMarshaler is responsible for transforming the List<Integer> data into a finalized response representation: an instance of MarshaledResponse, optionally with a MarshaledResponseBody.
Writing JSON Responses
Want to see how this looks in practice? Jump to Response Marshaling.
Soklet does not dictate how to encode your data - whether you use JSON or Protocol Buffers or XML or something else is entirely up to you and your application's needs.
If a Resource Method returns an instance of Response, it is provided as-is to your ResponseMarshaler. This permits Resource Methods to explicitly specify response customizations, e.g. explicit status codes, cookies, or other headers.
// This is equivalent to the method above that returns List<Integer>.
// Returning a Response instance enables customizations
@POST("/reverse-again")
public Response reverseAgain(@RequestBody List<Integer> numbers) {
return Response.withStatusCode(200)
.cookies(Set.of( /* any cookies you like */ ))
.headers(Map.of( /* any headers you like */ ))
.body(numbers.reversed())
.build();
}
// This is equivalent to the method above that returns List<Integer>.
// Returning a Response instance enables customizations
@POST("/reverse-again")
public Response reverseAgain(@RequestBody List<Integer> numbers) {
return Response.withStatusCode(200)
.cookies(Set.of( /* any cookies you like */ ))
.headers(Map.of( /* any headers you like */ ))
.body(numbers.reversed())
.build();
}
If a Resource Method returns an instance of MarshaledResponse, your ResponseMarshaler is not invoked because you are indicating "I want to send exactly this content over the wire; no further processing is needed."
This is useful for one-off kinds of responses, e.g. an API that normally serves up JSON but occasionally needs to serve up an image or other binary file:
// Use MarshaledResponse to serve up an image
@GET("/example-image.png")
public MarshaledResponse exampleImage() {
Path imageFile = Path.of("/home/user/test.png");
return MarshaledResponse.withStatusCode(200)
.body(imageFile)
.headers(Map.of(
"Content-Type", Set.of("image/png")
))
.build();
}
// Use MarshaledResponse to serve up an image
@GET("/example-image.png")
public MarshaledResponse exampleImage() {
Path imageFile = Path.of("/home/user/test.png");
return MarshaledResponse.withStatusCode(200)
.body(imageFile)
.headers(Map.of(
"Content-Type", Set.of("image/png")
))
.build();
}
Serving Files Efficiently
Want to see how file, file-channel, and pre-rendered buffer responses work? Jump to Zero-Copy Responses.
Streaming Dynamic Responses
For dynamic output where the final length is not known upfront, return a MarshaledResponse with a StreamingResponseBody. See Streaming Responses.
If a Resource Method returns an instance of any other type or void or null, a Response is synthetically created by Soklet and passed along to your ResponseMarshaler.
Response Status
By default, any Resource Method that executes non-exceptionally will return a 200 OK status (or 204 No Content for null values and void return types).
@GET("/status")
public String status() {
return "Hello!";
}
@GET("/status")
public String status() {
return "Hello!";
}
To test:
% curl -i 'http://localhost:8080/status'
HTTP/1.1 200 OK
Content-Length: 6
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello!
% curl -i 'http://localhost:8080/status'
HTTP/1.1 200 OK
Content-Length: 6
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello!
To specify a custom status, your Resource Method should return a Response. Use the Response::withStatusCode builder method to instantiate.
@POST("/widgets")
public Response createWidget(WidgetService widgetService) {
// Hypothetical widget service
widgetService.createWidget();
// Reply with HTTP 201 Created
return Response.withStatusCode(201)
.body("Widget created.")
.build();
}
@POST("/widgets")
public Response createWidget(WidgetService widgetService) {
// Hypothetical widget service
widgetService.createWidget();
// Reply with HTTP 201 Created
return Response.withStatusCode(201)
.body("Widget created.")
.build();
}
To test:
% curl -i -X POST 'http://localhost:8080/widgets'
HTTP/1.1 201 Created
Content-Length: 15
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Widget created.
% curl -i -X POST 'http://localhost:8080/widgets'
HTTP/1.1 201 Created
Content-Length: 15
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Widget created.
There are other scenarios in which response statuses may be set. Examples include:
Generally, your Response Marshaling configuration makes the determination of what status will ultimately go over the wire.
Response Headers
To specify custom response headers, your Resource Method should return a Response constructed with the Response.Builder::headers method.
@GET("/headers")
public Response headers() {
return Response.withStatusCode(200)
.headers(Map.of("One", Set.of("Two", "Three")))
.body("Hello, world")
.build();
}
@GET("/headers")
public Response headers() {
return Response.withStatusCode(200)
.headers(Map.of("One", Set.of("Two", "Three")))
.body("Hello, world")
.build();
}
Because the set of header values were not already sorted, Soklet will apply natural sort ordering (One: Three, Two) in order to maintain consistency across requests.
$ curl -i 'http://localhost:8080/headers'
HTTP/1.1 200 OK
Content-Length: 12
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
One: Three
One: Two
Hello, world
$ curl -i 'http://localhost:8080/headers'
HTTP/1.1 200 OK
Content-Length: 12
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
One: Three
One: Two
Hello, world
If header value sort order is important to you, specify either a SortedSet<E> or LinkedHashSet<E>.
@GET("/headers/sorted")
public Response headersSorted() {
// LinkedHashSet preserves insertion order
Set<String> twoAndThree = new LinkedHashSet<>();
twoAndThree.add("Two");
twoAndThree.add("Three");
// TreeSet is naturally ordered
SortedSet<String> fiveAndSix = new TreeSet<>();
fiveAndSix.add("6");
fiveAndSix.add("5");
return Response.withStatusCode(200)
.headers(Map.of(
"One", twoAndThree,
"Four", fiveAndSix
))
.body("Hello, world")
.build();
}
@GET("/headers/sorted")
public Response headersSorted() {
// LinkedHashSet preserves insertion order
Set<String> twoAndThree = new LinkedHashSet<>();
twoAndThree.add("Two");
twoAndThree.add("Three");
// TreeSet is naturally ordered
SortedSet<String> fiveAndSix = new TreeSet<>();
fiveAndSix.add("6");
fiveAndSix.add("5");
return Response.withStatusCode(200)
.headers(Map.of(
"One", twoAndThree,
"Four", fiveAndSix
))
.body("Hello, world")
.build();
}
The header values are then written as Two, Three and 5, 6.
% curl -i 'http://localhost:8080/headers/sorted'
HTTP/1.1 200 OK
Content-Length: 12
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Four: 5
Four: 6
One: Two
One: Three
Hello, world
% curl -i 'http://localhost:8080/headers/sorted'
HTTP/1.1 200 OK
Content-Length: 12
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Four: 5
Four: 6
One: Two
One: Three
Hello, world
Parameterized Header Values
Some response headers use a special "value; param=value" format (for example, Content-Disposition and Content-Type), which are more nuanced than they might first appear.
Soklet provides ParameterizedHeaderValue to build these safely with strict RFC encoding rules. Invalid tokens or control characters will fail fast, preventing you from creating out-of-spec responses.
All 3 parameter types are supported:
ParameterizedHeaderValue.Builder::tokenParameterfor RFC 9110tokenparametersParameterizedHeaderValue.Builder::quotedParameterfor RFC 9110quoted-stringparametersParameterizedHeaderValue.Builder::rfc8187Parameterfor RFC 8187ext-valueparameters (UTF-8 with percent-encoding)
@GET("/resume.pdf")
public MarshaledResponse resume() {
Path resumeFile = Path.of("/home/user/resume.pdf");
// Construct our "attachment=..." Content-Disposition value
String contentDisposition = ParameterizedHeaderValue.withName("attachment")
.quotedParameter("filename", "resume.pdf")
.rfc8187Parameter("filename", "résumé.pdf")
.stringValue();
return MarshaledResponse.withStatusCode(200)
.body(resumeFile)
.headers(Map.of(
"Content-Disposition", Set.of(contentDisposition),
"Content-Type", Set.of("application/pdf")
))
.build();
}
@GET("/resume.pdf")
public MarshaledResponse resume() {
Path resumeFile = Path.of("/home/user/resume.pdf");
// Construct our "attachment=..." Content-Disposition value
String contentDisposition = ParameterizedHeaderValue.withName("attachment")
.quotedParameter("filename", "resume.pdf")
.rfc8187Parameter("filename", "résumé.pdf")
.stringValue();
return MarshaledResponse.withStatusCode(200)
.body(resumeFile)
.headers(Map.of(
"Content-Disposition", Set.of(contentDisposition),
"Content-Type", Set.of("application/pdf")
))
.build();
}
The resulting Content-Disposition header value is:
attachment; filename="resume.pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf
attachment; filename="resume.pdf"; filename*=UTF-8''r%C3%A9sum%C3%A9.pdf
Which Type[s] Should I Use?
Use the narrowest one that fits your data:
tokenParameterfor strict RFC 9110 tokens (ASCII only, no spaces, no quotes). Good for values likecharset=UTF-8.quotedParameterfor ASCII values that may include spaces or punctuation; Soklet will quote and escape as needed.rfc8187Parameterfor non-ASCII values; Soklet encodes UTF-8 and percent-escapes it per RFC 8187.
For legacy compatibility (notably Content-Disposition filenames), send both a quotedParameter with a safe ASCII fallback and an rfc8187Parameter with the full Unicode value.
Modern clients prefer filename*, while older ones only understand filename.
Response Cookies
To specify response cookies, your Resource Method should return a Response constructed with a set of ResponseCookie.
@GET("/current-date")
public Response currentDate(@QueryParameter ZoneId timeZone) {
LocalDate date = LocalDate.now(timeZone);
Instant now = Instant.now();
return Response.withStatusCode(200)
.cookies(Set.of(
ResponseCookie.with("now", String.valueOf(now))
.build(),
ResponseCookie.with("lastDate", date.toString())
.httpOnly(true)
.secure(true)
.maxAge(Duration.ofMinutes(5))
.sameSite(SameSite.LAX)
.build()
))
.build();
}
@GET("/current-date")
public Response currentDate(@QueryParameter ZoneId timeZone) {
LocalDate date = LocalDate.now(timeZone);
Instant now = Instant.now();
return Response.withStatusCode(200)
.cookies(Set.of(
ResponseCookie.with("now", String.valueOf(now))
.build(),
ResponseCookie.with("lastDate", date.toString())
.httpOnly(true)
.secure(true)
.maxAge(Duration.ofMinutes(5))
.sameSite(SameSite.LAX)
.build()
))
.build();
}
To test:
% curl -i 'http://localhost:8080/current-date?timeZone=America/New_York'
HTTP/1.1 200 OK
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Set-Cookie: lastDate=2024-04-21; Max-Age=300; Secure; HttpOnly; SameSite=Lax
Set-Cookie: now=2024-04-21T16:19:01.128027Z
% curl -i 'http://localhost:8080/current-date?timeZone=America/New_York'
HTTP/1.1 200 OK
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Set-Cookie: lastDate=2024-04-21; Max-Age=300; Secure; HttpOnly; SameSite=Lax
Set-Cookie: now=2024-04-21T16:19:01.128027Z
To delete a cookie, specify its name and provide a null value:
return Response.withStatusCode(200)
.cookies(Set.of(ResponseCookie.with("lastDate", null).build()))
.build();
return Response.withStatusCode(200)
.cookies(Set.of(ResponseCookie.with("lastDate", null).build()))
.build();
Soklet supports the full Set-Cookie attribute surface, including Expires, Priority, and Partitioned:
ResponseCookie sessionCookie = ResponseCookie.with("session", token)
.expires(Instant.now().plus(Duration.ofDays(7)))
.priority(Priority.HIGH)
.partitioned(true)
.secure(true)
.sameSite(SameSite.NONE)
.path("/")
.build();
ResponseCookie sessionCookie = ResponseCookie.with("session", token)
.expires(Instant.now().plus(Duration.ofDays(7)))
.priority(Priority.HIGH)
.partitioned(true)
.secure(true)
.sameSite(SameSite.NONE)
.path("/")
.build();
Validation
Soklet validates ResponseCookie instances at construction time and fails fast when invalid. For example:
Max-Agemust be>= 0SameSite=NonerequiresSecurePartitionedrequiresSecure,SameSite=None, andPath=/__Secure-cookie names requireSecure__Host-cookie names requireSecure,Path=/, and noDomain
If you need to parse existing Set-Cookie headers, ResponseCookie::fromSetCookieHeaderRepresentation understands Expires, SameSite, Priority, and Partitioned along with the standard attributes.
The response cookie construct is provided as a convenience shorthand so you do not have to compute values for Set-Cookie headers yourself. However, if you prefer, you can write the headers directly - see Response Headers for details.
It is unusual but legal to specify multiple response cookies with the same name. Soklet supports this functionality.
By default, Soklet will sort Set-Cookie response headers alphabetically. If sort order for cookies is important to you, specify with either a SortedSet<E> or LinkedHashSet<E>.
@GET("/double-date")
public Response doubleDate(@QueryParameter ZoneId timeZone) {
LocalDate date = LocalDate.now(timeZone);
Instant now = Instant.now();
Instant later = now.plus(1, ChronoUnit.MINUTES);
// Explicitly order cookies, which happen to have the same name.
// Here, put "later" before "now"
Set<ResponseCookie> cookies = new LinkedHashSet<>();
cookies.add(ResponseCookie.with("now", String.valueOf(later)).build());
cookies.add(ResponseCookie.with("now", String.valueOf(now)).build());
return Response.withStatusCode(200)
.cookies(cookies)
.build();
}
@GET("/double-date")
public Response doubleDate(@QueryParameter ZoneId timeZone) {
LocalDate date = LocalDate.now(timeZone);
Instant now = Instant.now();
Instant later = now.plus(1, ChronoUnit.MINUTES);
// Explicitly order cookies, which happen to have the same name.
// Here, put "later" before "now"
Set<ResponseCookie> cookies = new LinkedHashSet<>();
cookies.add(ResponseCookie.with("now", String.valueOf(later)).build());
cookies.add(ResponseCookie.with("now", String.valueOf(now)).build());
return Response.withStatusCode(200)
.cookies(cookies)
.build();
}
To test:
% curl -i 'http://localhost:8080/double-date?timeZone=America/New_York'
HTTP/1.1 200 OK
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Set-Cookie: now=2024-04-21T16:20:01.706488Z
Set-Cookie: now=2024-04-21T16:19:01.706488Z
% curl -i 'http://localhost:8080/double-date?timeZone=America/New_York'
HTTP/1.1 200 OK
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Set-Cookie: now=2024-04-21T16:20:01.706488Z
Set-Cookie: now=2024-04-21T16:19:01.706488Z
Response Marshaling
Hooks are provided for these scenarios:
- Resource Method
- Uncaught Exceptions
- 404 Not Found
- 405 Method Not Allowed
- HTTP
OPTIONS - HTTP
HEAD - CORS
- Post-Processing
Normally, you'll want to provide hooks to Soklet's standard ResponseMarshaler implementation (as opposed to implementing the interface yourself) because it provides sensible defaults for things like CORS, OPTIONS, HEAD, and 404s/405s. This way, you can stay focused on how your application writes "normal" Resource Method and uncaught exception responses and let Soklet worry about the rest.
Keep It Simple
Most applications only need to provide handlers for Resource Method and Uncaught Exception scenarios.
But if you'd like to, for example, control how responses are written for HEAD requests - you have the ability to do so. See Advanced Response Marshaling.
Resource Method
This means the request was successfully matched to a Resource Method and executed without throwing an exception.
You have access to the Request, Response, and ResourceMethod that handled the request.
// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();
// The request was matched to a Resource Method and executed non-exceptionally
ResourceMethodHandler resourceMethodHandler = (
@NonNull Request request,
@NonNull Response response,
@NonNull ResourceMethod resourceMethod
) -> {
// Turn response body into JSON bytes with Gson
Object bodyObject = response.getBody().orElse(null);
byte[] body = bodyObject == null
? null
: GSON.toJson(bodyObject).getBytes(StandardCharsets.UTF_8);
// To be a good citizen, set the Content-Type header
Map<String, Set<String>> headers = new HashMap<>(response.getHeaders());
headers.put("Content-Type", Set.of("application/json;charset=UTF-8"));
// Tell Soklet: "OK - here is the final response data to send"
return MarshaledResponse.withResponse(response)
.headers(headers)
.body(body)
.build();
};
// Provide the default marshaler with our handler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(resourceMethodHandler)
.build()
).build();
// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();
// The request was matched to a Resource Method and executed non-exceptionally
ResourceMethodHandler resourceMethodHandler = (
@NonNull Request request,
@NonNull Response response,
@NonNull ResourceMethod resourceMethod
) -> {
// Turn response body into JSON bytes with Gson
Object bodyObject = response.getBody().orElse(null);
byte[] body = bodyObject == null
? null
: GSON.toJson(bodyObject).getBytes(StandardCharsets.UTF_8);
// To be a good citizen, set the Content-Type header
Map<String, Set<String>> headers = new HashMap<>(response.getHeaders());
headers.put("Content-Type", Set.of("application/json;charset=UTF-8"));
// Tell Soklet: "OK - here is the final response data to send"
return MarshaledResponse.withResponse(response)
.headers(headers)
.body(body)
.build();
};
// Provide the default marshaler with our handler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(resourceMethodHandler)
.build()
).build();
Having access to the ResourceMethod that handled the request lets you define processing "hints" (among other things) without warping your method's contract.
For example, we can declare a special annotation...
// An annotation that indicates
// "include debugging information in the response"
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Debug {}
public class ExampleResource {
@Debug // Apply the annotation
@GET("/example")
public String example() {
return "hello, world";
}
}
// An annotation that indicates
// "include debugging information in the response"
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Debug {}
public class ExampleResource {
@Debug // Apply the annotation
@GET("/example")
public String example() {
return "hello, world";
}
}
...and then access it via reflection at runtime:
ResourceMethodHandler resourceMethodHandler = (
@NonNull Request request,
@NonNull Response response,
@NonNull ResourceMethod resourceMethod
) -> {
// Prepare standard response headers
Map<String, Set<String>> headers = new HashMap<>(response.getHeaders());
headers.put("Content-Type", Set.of("application/json;charset=UTF-8"));
// If our annotation is present, add some debug headers
if(resourceMethod.getMethod().isAnnotationPresent(Debug.class)) {
headers.put("X-Debug-Id", Set.of(
String.valueOf(request.getId())
));
headers.put("X-Language-Tag", Set.of(
request.getLocales().get(0).toLanguageTag()
));
}
// Rest of method elided
};
ResourceMethodHandler resourceMethodHandler = (
@NonNull Request request,
@NonNull Response response,
@NonNull ResourceMethod resourceMethod
) -> {
// Prepare standard response headers
Map<String, Set<String>> headers = new HashMap<>(response.getHeaders());
headers.put("Content-Type", Set.of("application/json;charset=UTF-8"));
// If our annotation is present, add some debug headers
if(resourceMethod.getMethod().isAnnotationPresent(Debug.class)) {
headers.put("X-Debug-Id", Set.of(
String.valueOf(request.getId())
));
headers.put("X-Language-Tag", Set.of(
request.getLocales().get(0).toLanguageTag()
));
}
// Rest of method elided
};
References:
Uncaught Exceptions
If an exception is thrown while Soklet is processing a request, it is surfaced here so a representation of the error can be written to the response.
Soklet lets you write your applications "normally" - that is, define your own exception types and let them bubble out. There is generally no need to have your application code throw or extend Soklet-specific exceptions.
Because Soklet is responsible for converting request data to the types specified by your Resource Methods via Value Conversions, an abstract BadRequestException and its specific subclasses like IllegalQueryParameterException are available out-of-the-box.
For example, suppose your Resource Method requires a request body, but the client does not provide one. Soklet will throw a MissingRequestBodyException.
The entire set of BadRequestException types is available in the com.soklet.exception package.
// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();
// Function to create responses for exceptions that bubble out
ThrowableHandler throwableHandler = (
@NonNull Request request,
@NonNull Throwable throwable,
@Nullable ResourceMethod resourceMethod
) -> {
// Keep track of what to write to the response
String message;
int statusCode;
// Examine the exception that bubbled out and determine what
// the HTTP status and a user-facing message should be.
// Note: real systems should localize these messages
switch (throwable) {
// Soklet throws this exception - it's a
// specific subclass of BadRequestException
case IllegalQueryParameterException e -> {
message = String.format("Illegal value '%s' for parameter '%s'",
e.getQueryParameterValue().orElse("[not provided]"),
e.getQueryParameterName());
statusCode = 400;
}
// Generically handle other BadRequestExceptions
case BadRequestException ignored -> {
message = "Your request was improperly formatted.";
statusCode = 400;
}
// Something else? Fall back to a 500
default -> {
message = "An unexpected error occurred.";
statusCode = 500;
}
}
// Turn response body into JSON bytes with Gson.
// Note: real systems should expose richer error constructs
// than an object with a single message field
byte[] body = GSON.toJson(Map.of("message", message))
.getBytes(StandardCharsets.UTF_8);
// Specify our headers
Map<String, Set<String>> headers = new HashMap<>();
headers.put("Content-Type", Set.of("application/json;charset=UTF-8"));
return MarshaledResponse.withStatusCode(statusCode)
.headers(headers)
.body(body)
.build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.throwableHandler(throwableHandler)
.build()
).build();
// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();
// Function to create responses for exceptions that bubble out
ThrowableHandler throwableHandler = (
@NonNull Request request,
@NonNull Throwable throwable,
@Nullable ResourceMethod resourceMethod
) -> {
// Keep track of what to write to the response
String message;
int statusCode;
// Examine the exception that bubbled out and determine what
// the HTTP status and a user-facing message should be.
// Note: real systems should localize these messages
switch (throwable) {
// Soklet throws this exception - it's a
// specific subclass of BadRequestException
case IllegalQueryParameterException e -> {
message = String.format("Illegal value '%s' for parameter '%s'",
e.getQueryParameterValue().orElse("[not provided]"),
e.getQueryParameterName());
statusCode = 400;
}
// Generically handle other BadRequestExceptions
case BadRequestException ignored -> {
message = "Your request was improperly formatted.";
statusCode = 400;
}
// Something else? Fall back to a 500
default -> {
message = "An unexpected error occurred.";
statusCode = 500;
}
}
// Turn response body into JSON bytes with Gson.
// Note: real systems should expose richer error constructs
// than an object with a single message field
byte[] body = GSON.toJson(Map.of("message", message))
.getBytes(StandardCharsets.UTF_8);
// Specify our headers
Map<String, Set<String>> headers = new HashMap<>();
headers.put("Content-Type", Set.of("application/json;charset=UTF-8"));
return MarshaledResponse.withStatusCode(statusCode)
.headers(headers)
.body(body)
.build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.throwableHandler(throwableHandler)
.build()
).build();
If an exception bubbles out during execution of ResponseMarshaler::forThrowable, Soklet will create its own "failsafe" response and attempt to write it to the client.
References:
404 Not Found
This is invoked by Soklet if no matching Resource Method was found for the Request.
// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();
// Function to create responses for when no Resource Method
// was found to service the request
NotFoundHandler notFoundHandler = (
@NonNull Request request
) -> {
// Specify our headers
Map<String, Set<String>> headers = Map.of(
"Content-Type", Set.of("application/json;charset=UTF-8")
);
// Generate response body JSON as a byte array with Gson.
// Note: real systems should localize this message
String message = "Resource not found.";
byte[] body = GSON.toJson(Map.of("message", message))
.getBytes(StandardCharsets.UTF_8);
return MarshaledResponse.withStatusCode(404)
.headers(headers)
.body(body)
.build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.notFoundHandler(notFoundHandler)
.build()
).build();
// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();
// Function to create responses for when no Resource Method
// was found to service the request
NotFoundHandler notFoundHandler = (
@NonNull Request request
) -> {
// Specify our headers
Map<String, Set<String>> headers = Map.of(
"Content-Type", Set.of("application/json;charset=UTF-8")
);
// Generate response body JSON as a byte array with Gson.
// Note: real systems should localize this message
String message = "Resource not found.";
byte[] body = GSON.toJson(Map.of("message", message))
.getBytes(StandardCharsets.UTF_8);
return MarshaledResponse.withStatusCode(404)
.headers(headers)
.body(body)
.build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.notFoundHandler(notFoundHandler)
.build()
).build();
References:
Post-Processing
Soklet provides an optional post-processing hook for final customization before data gets sent over the wire.
// A PostProcessor is applied after other handlers
PostProcessor postProcessor = (
@NonNull MarshaledResponse marshaledResponse
) -> {
// Copy the response and tack on an 'X-Powered-By' header
return marshaledResponse.copy()
// Copier convenience: acquire a temporarily-mutable copy
// of response headers so you can adjust as needed
.headers((Map<String, Set<String>> mutableHeaders) -> {
mutableHeaders.put("X-Powered-By", Set.of("Soklet"));
})
.finish();
};
// Supply our custom post-processor to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.throwableHandler(...)
.notFoundHandler(...)
.postProcessor(postProcessor)
.build()
).build();
// A PostProcessor is applied after other handlers
PostProcessor postProcessor = (
@NonNull MarshaledResponse marshaledResponse
) -> {
// Copy the response and tack on an 'X-Powered-By' header
return marshaledResponse.copy()
// Copier convenience: acquire a temporarily-mutable copy
// of response headers so you can adjust as needed
.headers((Map<String, Set<String>> mutableHeaders) -> {
mutableHeaders.put("X-Powered-By", Set.of("Soklet"));
})
.finish();
};
// Supply our custom post-processor to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.throwableHandler(...)
.notFoundHandler(...)
.postProcessor(postProcessor)
.build()
).build();
References:
Zero-Copy Responses
When a Resource Method already knows the exact body it wants to write, it can return MarshaledResponse directly. This bypasses normal response marshaling and lets Soklet's standard HTTP server write some known-length bodies more efficiently.
MarshaledResponseBody.File bodies are known-length. Soklet validates the path as a readable regular file, records its size, and the standard HTTP server can write it without first loading the whole file into a byte[].
There are three file-serving layers:
| API | Use it when | What it does |
|---|---|---|
MarshaledResponse.Builder::body(Path) | You already chose a file and only want that file as the response body | Adds a raw file body. You set status, Content-Type, cache headers, and any other headers yourself. It does not evaluate validators or ranges. |
MarshaledResponse::withFile | You already chose one trusted file, but want HTTP file-response semantics | Builds a request-aware file response with validators, Last-Modified, single byte ranges, If-Range, Content-Encoding, and HEAD behavior. It does not do static-root path containment. |
StaticFiles | A client-provided path participates in resolving a file under a static root | Performs root containment, path rejection, index lookup, symlink policy, MIME defaults, per-path resolvers, validators, and single byte ranges. |
You can also provide a file slice, a FileChannel, or a pre-rendered ByteBuffer:
// Zero-copy responses can use a regular file path...
Path filePath = Path.of("/home/user/export.bin");
Long offset = 1024L;
Long count = Files.size(filePath) - offset;
return MarshaledResponse.withStatusCode(206)
.headers(Map.of("Content-Type", Set.of("application/octet-stream")))
.body(filePath, offset, count)
.build();
// Zero-copy responses can use a regular file path...
Path filePath = Path.of("/home/user/export.bin");
Long offset = 1024L;
Long count = Files.size(filePath) - offset;
return MarshaledResponse.withStatusCode(206)
.headers(Map.of("Content-Type", Set.of("application/octet-stream")))
.body(filePath, offset, count)
.build();
// ...or an NIO FileChannel...
FileChannel fileChannel = FileChannel.open(
Path.of("/home/user/export.bin"),
StandardOpenOption.READ
);
Long offset = 0L;
Long count = fileChannel.size();
Boolean closeOnComplete = true;
return MarshaledResponse.withStatusCode(200)
.body(fileChannel, offset, count, closeOnComplete)
.headers(Map.of("Content-Type", Set.of("application/octet-stream")))
.build();
// ...or an NIO FileChannel...
FileChannel fileChannel = FileChannel.open(
Path.of("/home/user/export.bin"),
StandardOpenOption.READ
);
Long offset = 0L;
Long count = fileChannel.size();
Boolean closeOnComplete = true;
return MarshaledResponse.withStatusCode(200)
.body(fileChannel, offset, count, closeOnComplete)
.headers(Map.of("Content-Type", Set.of("application/octet-stream")))
.build();
// ...or an NIO ByteBuffer.
ByteBuffer cachedPayload = ByteBuffer.wrap(
"{\"status\":\"ok\"}".getBytes(StandardCharsets.UTF_8)
);
return MarshaledResponse.withStatusCode(200)
.body(cachedPayload)
.headers(Map.of("Content-Type", Set.of("application/json")))
.build();
// ...or an NIO ByteBuffer.
ByteBuffer cachedPayload = ByteBuffer.wrap(
"{\"status\":\"ok\"}".getBytes(StandardCharsets.UTF_8)
);
return MarshaledResponse.withStatusCode(200)
.body(cachedPayload)
.headers(Map.of("Content-Type", Set.of("application/json")))
.build();
For ByteBuffer bodies, the buffer's position and limit at call time define the response slice. Soklet stores a read-only slice and does not mutate the caller's buffer position. For file-channel bodies, pass false for closeOnComplete when you want to retain ownership of the channel, or true when Soklet should close it after success or failure.
For already-compressed files, use MarshaledResponse::withFile and set contentEncoding("gzip"). Soklet does not automatically negotiate precompressed file variants; if the chosen file depends on Accept-Encoding, add an appropriate Vary: Accept-Encoding header yourself. Standard HTTP's opt-in responseGzipPolicy setting applies to finalized in-memory byte-array and ByteBuffer responses, not file or streaming responses.
Use byte-array bodies when your marshaler already has finalized bytes in memory:
// Here we turn JSON into UTF-8 bytes
byte[] body = GSON.toJson(Map.of("status", "ok"))
.getBytes(StandardCharsets.UTF_8);
return MarshaledResponse.withStatusCode(200)
.headers(Map.of("Content-Type", Set.of("application/json;charset=UTF-8")))
.body(body)
.build();
// Here we turn JSON into UTF-8 bytes
byte[] body = GSON.toJson(Map.of("status", "ok"))
.getBytes(StandardCharsets.UTF_8);
return MarshaledResponse.withStatusCode(200)
.headers(Map.of("Content-Type", Set.of("application/json;charset=UTF-8")))
.body(body)
.build();
Passing null to a single-argument MarshaledResponse body builder method removes any current body. This lets custom marshalers pass nullable serialized bytes directly when the logical response has no body. Multi-argument body methods require a non-null body source because their offset, count, and ownership parameters only make sense when that source is present.
MarshaledResponse::getBody returns an Optional containing a MarshaledResponseBody. Inspect the concrete descriptor when you need to distinguish byte-array, file, file-channel, or ByteBuffer bodies:
// You might need to work with a marshaled response body
// directly in test code, e.g. verifying JSON sent over the wire
// See "Testing" documentation for examples
MarshaledResponseBody responseBody =
marshaledResponse.getBody().orElseThrow();
if (responseBody instanceof MarshaledResponseBody.Bytes bytesBody) {
// Do something with a byte-array-backed body
byte[] bytes = bytesBody.getBytes();
} else if (responseBody instanceof MarshaledResponseBody.File fileBody) {
// Do something with a file-backed body
Path file = fileBody.getPath();
Long offset = fileBody.getOffset();
Long count = fileBody.getCount();
}
// You might need to work with a marshaled response body
// directly in test code, e.g. verifying JSON sent over the wire
// See "Testing" documentation for examples
MarshaledResponseBody responseBody =
marshaledResponse.getBody().orElseThrow();
if (responseBody instanceof MarshaledResponseBody.Bytes bytesBody) {
// Do something with a byte-array-backed body
byte[] bytes = bytesBody.getBytes();
} else if (responseBody instanceof MarshaledResponseBody.File fileBody) {
// Do something with a file-backed body
Path file = fileBody.getPath();
Long offset = fileBody.getOffset();
Long count = fileBody.getCount();
}
If you only need the number of body bytes, use MarshaledResponse::getBodyLength; it works for all known-length body types.
References:
MarshaledResponseMarshaledResponseBodyMarshaledResponseBody.BytesMarshaledResponseBody.FileMarshaledResponseBody.FileChannelMarshaledResponseBody.ByteBuffer
Streaming Responses
Use StreamingResponseBody when a response is produced over time and the final byte length is not known upfront. Common examples include LLM token streams, log tails, generated CSV or NDJSON exports, and long-running backend calls where you want clients to see progress before the full result exists.
Zero-copy and streaming responses solve related but different problems. Use zero-copy responses when the response is already a known-length byte sequence, especially a file or file slice. Use streaming responses when the bytes are produced incrementally and Soklet cannot know the final Content-Length before writing begins.
Streaming responses are intentionally returned through MarshaledResponse, not Response. Like file-backed or ByteBuffer bodies, a stream says "I already know what should be written to the HTTP response, and I need Soklet to use a specific write path." It bypasses normal Response body marshaling and uses stream(...) instead of body(...):
@GET("/tokens")
public MarshaledResponse tokens(TokenService tokenService) {
return MarshaledResponse.withStatusCode(200)
// Tell intermediaries this is plain streaming text
.headers(Map.of(
"Content-Type", Set.of("text/plain; charset=UTF-8"),
"Cache-Control", Set.of("no-transform")
))
.stream(StreamingResponseBody.fromWriter((output, context) -> {
// Stop upstream work promptly if the client goes away
try (AutoCloseable ignored = context.onCancel(tokenService::stop)) {
tokenService.generate(token -> {
context.throwIfCanceled();
output.write(token.getBytes(StandardCharsets.UTF_8));
output.flush(); // Optional latency hint
});
}
}))
.build();
}
@GET("/tokens")
public MarshaledResponse tokens(TokenService tokenService) {
return MarshaledResponse.withStatusCode(200)
// Tell intermediaries this is plain streaming text
.headers(Map.of(
"Content-Type", Set.of("text/plain; charset=UTF-8"),
"Cache-Control", Set.of("no-transform")
))
.stream(StreamingResponseBody.fromWriter((output, context) -> {
// Stop upstream work promptly if the client goes away
try (AutoCloseable ignored = context.onCancel(tokenService::stop)) {
tokenService.generate(token -> {
context.throwIfCanceled();
output.write(token.getBytes(StandardCharsets.UTF_8));
output.flush(); // Optional latency hint
});
}
}))
.build();
}
ResponseStream is intentionally not thread-safe. Use one active writer at a time; if your application produces bytes from multiple threads, serialize those writes before calling write(...). write(byte[]) and write(ByteBuffer) copy the bytes before returning, so callers may safely reuse their arrays or buffers after the write call completes.
flush() is an advisory latency hint, not a correctness requirement. Soklet always flushes remaining bytes when the stream completes.
Soklet writes streaming HTTP responses with HTTP/1.1 chunked transfer encoding. Do not set Content-Length or Transfer-Encoding yourself on a streaming MarshaledResponse; Soklet owns those protocol headers. Streaming responses are rejected for HTTP/1.0 requests with 505 HTTP Version Not Supported and a stream termination reason of PROTOCOL_UNSUPPORTED. Streaming bodies are also rejected for bodyless status codes like 204 No Content and 304 Not Modified.
Streaming Body Sources
The preferred dynamic APIs are writer and publisher based:
StreamingResponseBody writerBody =
StreamingResponseBody.fromWriter((output, context) -> {
// Write bytes as your application produces them
output.write("hello\n".getBytes(StandardCharsets.UTF_8));
});
StreamingResponseBody writerBody =
StreamingResponseBody.fromWriter((output, context) -> {
// Write bytes as your application produces them
output.write("hello\n".getBytes(StandardCharsets.UTF_8));
});
// Use this when you already have a JDK Flow.Publisher
StreamingResponseBody publisherBody =
StreamingResponseBody.fromPublisher(byteBufferPublisher);
// Use this when you already have a JDK Flow.Publisher
StreamingResponseBody publisherBody =
StreamingResponseBody.fromPublisher(byteBufferPublisher);
The Flow.Publisher option uses the JDK's built-in reactive-streams interfaces; no third-party dependency is involved. Soklet requests one item at a time from the publisher, copies emitted ByteBuffer contents before onNext returns, and applies its normal streaming backpressure queue. If the socket-side queue is full, onNext may block until space is available; this adapter is a simple bounded bridge, not a fully non-blocking reactive runtime. If the response is canceled before the publisher terminates, Soklet cancels the publisher subscription.
fromInputStream(...) and fromReader(...) are adapters for special cases where an existing source already exposes those JDK abstractions:
// Wrap an existing stream source without buffering the full object first
StreamingResponseBody inputStreamBody =
StreamingResponseBody.fromInputStream(() -> blobStore.openObjectStream(key));
// Wrap an existing stream source without buffering the full object first
StreamingResponseBody inputStreamBody =
StreamingResponseBody.fromInputStream(() -> blobStore.openObjectStream(key));
// Reader bodies declare the charset Soklet should use for encoding
StreamingResponseBody readerBody =
StreamingResponseBody.withReader(
() -> reportService.openReportReader(),
StandardCharsets.UTF_8
)
.malformedInputAction(CodingErrorAction.REPORT)
.unmappableCharacterAction(CodingErrorAction.REPORT)
.build();
// Reader bodies declare the charset Soklet should use for encoding
StreamingResponseBody readerBody =
StreamingResponseBody.withReader(
() -> reportService.openReportReader(),
StandardCharsets.UTF_8
)
.malformedInputAction(CodingErrorAction.REPORT)
.unmappableCharacterAction(CodingErrorAction.REPORT)
.build();
Reader bodies require an explicit charset. Encoder error actions default to CodingErrorAction.REPORT; use the builder methods when a legacy data source needs different JDK encoder behavior. If a stream is canceled while an InputStream or Reader body is blocked in a read, Soklet closes the source so ordinary close-aware JDK implementations can unblock and terminate.
Cancelation
Every streaming producer receives a StreamingResponseContext. It exposes a CancelationToken, the originating Request, an optional deadline, and an optional idle timeout.
Streaming producers can read parsed W3C trace data through context.getRequest().getTraceContext(); see Trace Context for parsing and propagation details.
Check the token between expensive operations and register cleanup callbacks for upstream work:
@GET("/ai/tokens")
public MarshaledResponse tokens(OpenAiClient openAiClient) {
return MarshaledResponse.withStatusCode(200)
// Keep the response simple: token bytes, no transformation
.headers(Map.of(
"Content-Type", Set.of("text/plain; charset=UTF-8"),
"Cache-Control", Set.of("no-transform")
))
.stream(StreamingResponseBody.fromWriter((output, context) -> {
// The request is still available inside the producer
Request request = context.getRequest();
String requestId = String.valueOf(request.getId());
OpenAiStream upstream = openAiClient.startStreaming(request);
// Closing the upstream stream releases work
// as soon as the client disconnects
try (AutoCloseable ignored = context.onCancel(upstream::close)) {
for (String token : upstream) {
context.throwIfCanceled();
log.debug("Streaming token for request {}", requestId);
output.write(token.getBytes(StandardCharsets.UTF_8));
}
}
}))
.build();
}
@GET("/ai/tokens")
public MarshaledResponse tokens(OpenAiClient openAiClient) {
return MarshaledResponse.withStatusCode(200)
// Keep the response simple: token bytes, no transformation
.headers(Map.of(
"Content-Type", Set.of("text/plain; charset=UTF-8"),
"Cache-Control", Set.of("no-transform")
))
.stream(StreamingResponseBody.fromWriter((output, context) -> {
// The request is still available inside the producer
Request request = context.getRequest();
String requestId = String.valueOf(request.getId());
OpenAiStream upstream = openAiClient.startStreaming(request);
// Closing the upstream stream releases work
// as soon as the client disconnects
try (AutoCloseable ignored = context.onCancel(upstream::close)) {
for (String token : upstream) {
context.throwIfCanceled();
log.debug("Streaming token for request {}", requestId);
output.write(token.getBytes(StandardCharsets.UTF_8));
}
}
}))
.build();
}
Soklet cancels the token when the client disconnects, the server stops, an HTTP/1.0 request attempts to use streaming, a streaming timeout fires, the producer fails, the simulator limit is exceeded, or producer code explicitly aborts by throwing a StreamingResponseCanceledException. Use StreamTerminationReason.APPLICATION_CANCELED when producer code intentionally aborts the stream. Cancelation callbacks run synchronously on the thread that performs cancelation, so keep them quick and non-blocking; dispatch slower cleanup to your own executor. The public reason enum is StreamTerminationReason, which is also used by SSE and MCP stream lifecycle callbacks.
if (userAbortedRequest) {
// Use an application cancelation reason for intentional aborts
throw new StreamingResponseCanceledException(
StreamTerminationReason.APPLICATION_CANCELED);
}
if (userAbortedRequest) {
// Use an application cancelation reason for intentional aborts
throw new StreamingResponseCanceledException(
StreamTerminationReason.APPLICATION_CANCELED);
}
References:
StreamingResponseBodyStreamingResponseWriterResponseStreamStreamingResponseContextRequestCancelationToken
Copying Responses
When you want to adjust an existing Response (for example, adding a header during post-processing), use Response::copy to get a mutable copier, change the fields you need, then call Response.Copier::finish to build the new instance.
This "copy-to-change" workflow is necessary because Response objects are effectively immutable.
Response updatedResponse = response.copy()
// Convenience method to get a mutable copy of
// existing headers which you can modify in-place
.headers((Map<String, Set<String>> mutableHeaders) -> {
mutableHeaders.put("X-Powered-By", Set.of("Soklet"));
})
.finish();
Response updatedResponse = response.copy()
// Convenience method to get a mutable copy of
// existing headers which you can modify in-place
.headers((Map<String, Set<String>> mutableHeaders) -> {
mutableHeaders.put("X-Powered-By", Set.of("Soklet"));
})
.finish();
The copy is shallow for performance (for example, the response body object reference is reused), so treat the original as immutable.
If you're working with a MarshaledResponse, it also provides MarshaledResponse::copy for the same purpose (tweaking headers, status, or body) before writing the final response.
Advanced Response Marshaling
Experts Only
You can normally use the out-of-the-box implementation provided by ResponseMarshaler::builder for the below scenarios instead of writing your own.
405 Method Not Allowed
This is invoked by Soklet if a Resource Method was matched for the Request, but the HTTP Method is inapplicable.
Here is an example of a custom implementation:
// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();
MethodNotAllowedHandler methodNotAllowedHandler = (
@NonNull Request request,
@NonNull Set<HttpMethod> allowedHttpMethods
) -> {
// Soklet provides the set of permitted HTTP methods
Set<String> allowedHttpMethodsAsStrings = allowedHttpMethods.stream()
.map(httpMethod -> httpMethod.name())
.collect(Collectors.toCollection(LinkedHashSet::new));
// Ensure we set the "Allow" header
Map<String, Set<String>> headers = Map.of(
"Allow", allowedHttpMethodsAsStrings,
"Content-Type", Set.of("application/json;charset=UTF-8")
);
// This response body tells callers what is expected
byte[] body = GSON.toJson(Map.of(
// Note: real systems should localize this message
"message", "Method not allowed.",
"requested", request.getHttpMethod().name(),
"allowed", allowedHttpMethodsAsStrings
)).getBytes(StandardCharsets.UTF_8);
return MarshaledResponse.withStatusCode(405)
.headers(headers)
.body(body)
.build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.methodNotAllowedHandler(methodNotAllowedHandler)
.build()
).build();
// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();
MethodNotAllowedHandler methodNotAllowedHandler = (
@NonNull Request request,
@NonNull Set<HttpMethod> allowedHttpMethods
) -> {
// Soklet provides the set of permitted HTTP methods
Set<String> allowedHttpMethodsAsStrings = allowedHttpMethods.stream()
.map(httpMethod -> httpMethod.name())
.collect(Collectors.toCollection(LinkedHashSet::new));
// Ensure we set the "Allow" header
Map<String, Set<String>> headers = Map.of(
"Allow", allowedHttpMethodsAsStrings,
"Content-Type", Set.of("application/json;charset=UTF-8")
);
// This response body tells callers what is expected
byte[] body = GSON.toJson(Map.of(
// Note: real systems should localize this message
"message", "Method not allowed.",
"requested", request.getHttpMethod().name(),
"allowed", allowedHttpMethodsAsStrings
)).getBytes(StandardCharsets.UTF_8);
return MarshaledResponse.withStatusCode(405)
.headers(headers)
.body(body)
.build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.methodNotAllowedHandler(methodNotAllowedHandler)
.build()
).build();
For example, suppose we configure a Resource Method to handle PUT, POST, and DELETE.
@PUT("/example")
@POST("/example")
@DELETE("/example")
public void example405() {
// Implementation doesn't matter
}
@PUT("/example")
@POST("/example")
@DELETE("/example")
public void example405() {
// Implementation doesn't matter
}
Accessing it with a GET and the above ResponseMarshaler will result in this response:
% curl -i 'http://localhost:8080/example'
HTTP/1.1 405 Method Not Allowed
Allow: DELETE, POST, PUT
Content-Length: 116
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"message": "Method not allowed.",
"requested": "GET",
"allowed": [
"DELETE",
"POST",
"PUT"
]
}
% curl -i 'http://localhost:8080/example'
HTTP/1.1 405 Method Not Allowed
Allow: DELETE, POST, PUT
Content-Length: 116
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"message": "Method not allowed.",
"requested": "GET",
"allowed": [
"DELETE",
"POST",
"PUT"
]
}
References:
413 Content Too Large
This is invoked by Soklet if the size of the Request is above the maximum size configured for the server. For the standard HTTP server, the configured limit applies to the whole received HTTP request, including request line, headers, transfer framing, and body bytes. These scenarios are usually triggered by a multipart file upload or an otherwise unusually large request body.
You can detect "content too large" status by querying Request::isContentTooLarge.
Use Caution
Depending on when the "content too large" state was reached, Request might contain incomplete sets of headers/cookies. It will always have a zero-length body. If the transport cannot parse enough data to identify a request target before the limit is exceeded, Soklet may close the connection instead of invoking this marshaling path.
Soklet is designed to power systems that exchange small "transactional" payloads. It can serve known-length file-backed responses, but request uploads are still buffered and bounded by the configured request-size limit; Soklet is not intended for handling multipart uploads at scale, buffering uploads to disk, streaming request bodies, etc.
Explicitly handling 413 Content Too Large is an unusual case.
Here is an example of a custom implementation:
// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();
ContentTooLargeHandler contentTooLargeHandler = (
@NonNull Request request,
@Nullable ResourceMethod resourceMethod
) -> {
// Specify our headers
Map<String, Set<String>> headers = Map.of(
"Content-Type", Set.of("application/json;charset=UTF-8")
);
// Turn response body into JSON bytes with Gson.
// Note: real systems should localize this message
String message = "Request was too large.";
byte[] body = GSON.toJson(Map.of("message", message))
.getBytes(StandardCharsets.UTF_8);
return MarshaledResponse.withStatusCode(413)
.headers(headers)
.body(body)
.build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.contentTooLargeHandler(contentTooLargeHandler)
.build()
).build();
// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();
ContentTooLargeHandler contentTooLargeHandler = (
@NonNull Request request,
@Nullable ResourceMethod resourceMethod
) -> {
// Specify our headers
Map<String, Set<String>> headers = Map.of(
"Content-Type", Set.of("application/json;charset=UTF-8")
);
// Turn response body into JSON bytes with Gson.
// Note: real systems should localize this message
String message = "Request was too large.";
byte[] body = GSON.toJson(Map.of("message", message))
.getBytes(StandardCharsets.UTF_8);
return MarshaledResponse.withStatusCode(413)
.headers(headers)
.body(body)
.build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.contentTooLargeHandler(contentTooLargeHandler)
.build()
).build();
Exercising by including a large file in the request body results in this response:
% curl -i -d '@very-big-file.mp4' 'http://localhost:8080/big-file'
HTTP/1.1 413 Content Too Large
Content-Length: 41
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"message": "Request was too large."
}
% curl -i -d '@very-big-file.mp4' 'http://localhost:8080/big-file'
HTTP/1.1 413 Content Too Large
Content-Length: 41
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"message": "Request was too large."
}
References:
503 Service Unavailable
Soklet returns a 503 Service Unavailable response when it must reject a request due to capacity limits, such as when the standard HTTP, SSE, or MCP transport exceeds its connection limit. See the Server Configuration documentation for how to adjust these limits.
The default 503 response includes a plain-text body and a Connection: close header to signal clients to terminate the connection and back off:
HTTP/1.1 503 Service Unavailable
Content-Type: text/plain; charset=UTF-8
Connection: close
HTTP 503: Service Unavailable
HTTP/1.1 503 Service Unavailable
Content-Type: text/plain; charset=UTF-8
Connection: close
HTTP 503: Service Unavailable
If you'd like to customize this response (for example, return JSON and add Retry-After), provide a ServiceUnavailableHandler:
ServiceUnavailableHandler serviceUnavailableHandler = (
@NonNull Request request,
@Nullable ResourceMethod resourceMethod
) -> {
int statusCode = 503;
// Turn response body into JSON bytes with Gson.
// Note: real systems should localize this message
String message = "Try again shortly.";
byte[] body = GSON.toJson(Map.of("message", message))
.getBytes(StandardCharsets.UTF_8);
return MarshaledResponse.withStatusCode(statusCode)
.headers(Map.of(
"Content-Type", Set.of("application/json;charset=UTF-8"),
"Connection", Set.of("close"),
"Retry-After", Set.of("10")
))
.body(body)
.build();
};
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.serviceUnavailableHandler(serviceUnavailableHandler)
.build()
).build();
ServiceUnavailableHandler serviceUnavailableHandler = (
@NonNull Request request,
@Nullable ResourceMethod resourceMethod
) -> {
int statusCode = 503;
// Turn response body into JSON bytes with Gson.
// Note: real systems should localize this message
String message = "Try again shortly.";
byte[] body = GSON.toJson(Map.of("message", message))
.getBytes(StandardCharsets.UTF_8);
return MarshaledResponse.withStatusCode(statusCode)
.headers(Map.of(
"Content-Type", Set.of("application/json;charset=UTF-8"),
"Connection", Set.of("close"),
"Retry-After", Set.of("10")
))
.body(body)
.build();
};
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.serviceUnavailableHandler(serviceUnavailableHandler)
.build()
).build();
References:
HTTP OPTIONS
This is invoked by Soklet if a Resource Method was matched for the Request and the HTTP Method is OPTIONS. Special handling is also available for OPTIONS * requests, which are not tied to Resource Methods.
Experts Only
If you're looking to customize OPTIONS handling, it's likely that you want to manually handle CORS scenarios.
Soklet already provides comprehensive CORS support. But if you prefer to roll your own, read on.
Here is an example of a custom implementation:
OptionsHandler optionsHandler = (
@NonNull Request request,
@NonNull Set<HttpMethod> allowedHttpMethods
) -> {
// Soklet examines your Resource Methods and provides you with
// the set of HTTP methods supported for the request.
// This enables you to easily write the "Allow" response header
Set<String> allowedHttpMethodsAsStrings = allowedHttpMethods.stream()
.map(httpMethod -> httpMethod.name())
.collect(Collectors.toSet());
// Normally OPTIONS is a 204 with an Allow header
return MarshaledResponse.withStatusCode(204)
.headers(Map.of("Allow", allowedHttpMethodsAsStrings))
.build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.optionsHandler(optionsHandler)
.build()
).build();
OptionsHandler optionsHandler = (
@NonNull Request request,
@NonNull Set<HttpMethod> allowedHttpMethods
) -> {
// Soklet examines your Resource Methods and provides you with
// the set of HTTP methods supported for the request.
// This enables you to easily write the "Allow" response header
Set<String> allowedHttpMethodsAsStrings = allowedHttpMethods.stream()
.map(httpMethod -> httpMethod.name())
.collect(Collectors.toSet());
// Normally OPTIONS is a 204 with an Allow header
return MarshaledResponse.withStatusCode(204)
.headers(Map.of("Allow", allowedHttpMethodsAsStrings))
.build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.optionsHandler(optionsHandler)
.build()
).build();
For example, suppose we configure a Resource Method to handle PUT, POST, and DELETE.
@PUT("/example")
@POST("/example")
@DELETE("/example")
public void exampleOptions() {
// Implementation doesn't matter
}
@PUT("/example")
@POST("/example")
@DELETE("/example")
public void exampleOptions() {
// Implementation doesn't matter
}
Accessing it with via OPTIONS and the above customized ResponseMarshaler will result in this response:
% curl -i -X OPTIONS 'http://localhost:8080/example'
HTTP/1.1 204 No Content
Allow: DELETE
Allow: OPTIONS
Allow: POST
Allow: PUT
Content-Length: 0
Date: Sun, 21 Mar 2024 16:19:01 GMT
% curl -i -X OPTIONS 'http://localhost:8080/example'
HTTP/1.1 204 No Content
Allow: DELETE
Allow: OPTIONS
Allow: POST
Allow: PUT
Content-Length: 0
Date: Sun, 21 Mar 2024 16:19:01 GMT
It's also possible for clients to issue a special OPTIONS * request (colloquially, OPTIONS Splat). This is a special HTTP/1.1 request defined in RFC 7231 which permits querying of server-wide capabilities, not the capabilities of a particular resource. For example, a load balancer might want to ask "is the system up?" without hitting an explicit health-check URL.
By default, Soklet will write an HTTP 200 OK response for OPTIONS * requests like this:
% curl -X OPTIONS --request-target '*' 'http://localhost:8080' -i
HTTP/1.1 200 OK
Allow: DELETE
Allow: GET
Allow: HEAD
Allow: OPTIONS
Allow: PATCH
Allow: POST
Allow: PUT
Content-Length: 0
Date: Sun, 21 Mar 2024 16:19:01 GMT
% curl -X OPTIONS --request-target '*' 'http://localhost:8080' -i
HTTP/1.1 200 OK
Allow: DELETE
Allow: GET
Allow: HEAD
Allow: OPTIONS
Allow: PATCH
Allow: POST
Allow: PUT
Content-Length: 0
Date: Sun, 21 Mar 2024 16:19:01 GMT
You may override this behavior by providing an OptionsSplatHandler:
OptionsSplatHandler optionsSplatHandler = (
@NonNull Request request
) -> {
// Expose a subset of HTTP methods in the Allow header
SortedSet<String> allowedHttpMethods =
new TreeSet<>(List.of("GET", "OPTIONS", "POST"));
// Normally OPTIONS * is a 200 with an Allow header,
// we add an extra 'Server' header here
return MarshaledResponse.withStatusCode(200)
.headers(Map.of(
"Allow", allowedHttpMethods,
"Server", Set.of("Soklet")
)).build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.optionsSplatHandler(optionsSplatHandler)
.build()
).build();
OptionsSplatHandler optionsSplatHandler = (
@NonNull Request request
) -> {
// Expose a subset of HTTP methods in the Allow header
SortedSet<String> allowedHttpMethods =
new TreeSet<>(List.of("GET", "OPTIONS", "POST"));
// Normally OPTIONS * is a 200 with an Allow header,
// we add an extra 'Server' header here
return MarshaledResponse.withStatusCode(200)
.headers(Map.of(
"Allow", allowedHttpMethods,
"Server", Set.of("Soklet")
)).build();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.optionsSplatHandler(optionsSplatHandler)
.build()
).build();
With the custom handler, OPTIONS * responses now look like this:
% curl -X OPTIONS --request-target '*' 'http://localhost:8080' -i
HTTP/1.1 200 OK
Allow: GET
Allow: OPTIONS
Allow: POST
Content-Length: 0
Date: Sun, 21 Mar 2024 16:19:01 GMT
Server: Soklet
% curl -X OPTIONS --request-target '*' 'http://localhost:8080' -i
HTTP/1.1 200 OK
Allow: GET
Allow: OPTIONS
Allow: POST
Content-Length: 0
Date: Sun, 21 Mar 2024 16:19:01 GMT
Server: Soklet
References:
HTTP HEAD
This is invoked by Soklet if a Resource Method was matched for the Request and the HTTP Method is HEAD. The idea behind HEAD is for the client to determine the size of the response body that would be returned by a GET request but without the server providing the body.
Four rules:
- A
HEADmust never write a response body - A
HEADfor a known-length response must have itsContent-Lengthheader set to the number of bytes that theGETresponse body would have been - A
HEADmust pass through whatever HTTP status would have been returned by theGETresponse body - A
HEADshould pass through whatever HTTP headers would have been returned by theGETresponse
For streaming responses, the default HEAD behavior removes the stream and omits Content-Length, because the GET length is not known upfront.
Here is an example of a custom implementation (which is essentially identical to Soklet's default implementation for known-length responses):
HeadHandler headHandler = (
@NonNull Request request,
@NonNull MarshaledResponse getMethodMarshaledResponse
) -> {
// Soklet automatically marshals the GET response for you
Long getResponseLength = getMethodMarshaledResponse.getBodyLength();
// MarshaledResponse is immutable so we make a mutable copy
// of the GET response to construct our HEAD response.
MarshaledResponse.Copier responseCopier = getMethodMarshaledResponse.copy()
// Ensure no response body bytes are written
.withoutBody()
.withoutStream()
.cookies(getMethodMarshaledResponse.getCookies());
if (getMethodMarshaledResponse.isStreaming()) {
responseCopier.headers((mutableHeaders) -> {
mutableHeaders.remove("Content-Length");
});
} else {
responseCopier.headers((mutableHeaders) -> {
// For convenience, Soklet provides a mutable representation
// of the response headers when using the copy builder.
// Here, we specify our own Content-Length value using
// the length of the GET response body
mutableHeaders.put("Content-Length", Set.of(
String.valueOf(getResponseLength)
));
});
}
return responseCopier.finish();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.headHandler(headHandler)
.build()
).build();
HeadHandler headHandler = (
@NonNull Request request,
@NonNull MarshaledResponse getMethodMarshaledResponse
) -> {
// Soklet automatically marshals the GET response for you
Long getResponseLength = getMethodMarshaledResponse.getBodyLength();
// MarshaledResponse is immutable so we make a mutable copy
// of the GET response to construct our HEAD response.
MarshaledResponse.Copier responseCopier = getMethodMarshaledResponse.copy()
// Ensure no response body bytes are written
.withoutBody()
.withoutStream()
.cookies(getMethodMarshaledResponse.getCookies());
if (getMethodMarshaledResponse.isStreaming()) {
responseCopier.headers((mutableHeaders) -> {
mutableHeaders.remove("Content-Length");
});
} else {
responseCopier.headers((mutableHeaders) -> {
// For convenience, Soklet provides a mutable representation
// of the response headers when using the copy builder.
// Here, we specify our own Content-Length value using
// the length of the GET response body
mutableHeaders.put("Content-Length", Set.of(
String.valueOf(getResponseLength)
));
});
}
return responseCopier.finish();
};
// Supply our custom handler to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.headHandler(headHandler)
.build()
).build();
Given this Resource Method:
@GET("/hello")
public String hello() {
return "world";
}
@GET("/hello")
public String hello() {
return "world";
}
...here's how a GET might look:
% curl -i -X GET 'http://localhost:8080/hello'
HTTP/1.1 200 OK
Content-Length: 5
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
world
% curl -i -X GET 'http://localhost:8080/hello'
HTTP/1.1 200 OK
Content-Length: 5
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
world
...and here's how a HEAD might look:
% curl --head -i 'http://localhost:8080/hello'
HTTP/1.1 200 OK
Content-Length: 5
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
% curl --head -i 'http://localhost:8080/hello'
HTTP/1.1 200 OK
Content-Length: 5
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
References:
CORS
If Soklet sees an Origin header is specified in the Request - that is, it appears to be a CORS request - it consults its configured CorsAuthorizer, which provides preflight requests with a thumbs-up or thumbs-down and supplements approved requests with additional data in the response headers.
Experts Only
Unless you have special requirements, you should prefer to use Soklet's CORS support instead of writing your own.
Please refer to Soklet's CORS documentation for a comprehensive treatment of CORS, from basic whitelisting of permitted domains to full customization of authorization and response writing.
Should you prefer to roll your own CORS responses, the hooks below are available to you:
// Preflight: the request is allowed
CorsPreflightAllowedHandler corsPreflightAllowedHandler = (
@NonNull Request request,
@NonNull CorsPreflight corsPreflight,
@NonNull CorsPreflightResponse corsPreflightResponse
) -> {
// Return a MarshaledResponse with headers like
// Access-Control-Allow-Origin, Vary, etc.
};
// Preflight: the request is not allowed
CorsPreflightRejectedHandler corsPreflightRejectedHandler = (
@NonNull Request request,
@NonNull CorsPreflight corsPreflight
) -> {
// Return a MarshaledResponse with 403 or similar status
};
// Supplement responses for requests that have "passed" preflight
CorsAllowedHandler corsAllowedHandler = (
@NonNull Request request,
@NonNull Cors cors,
@NonNull CorsResponse corsResponse,
@NonNull MarshaledResponse marshaledResponse
) -> {
// Supplement the provided MarshaledResponse with
// Access-Control-Allow-Origin, Vary, etc. and return it
};
// Supply our custom handlers to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.corsPreflightAllowedHandler(corsPreflightAllowedHandler)
.corsPreflightRejectedHandler(corsPreflightRejectedHandler)
.corsAllowedHandler(corsAllowedHandler)
.build()
).build();
// Preflight: the request is allowed
CorsPreflightAllowedHandler corsPreflightAllowedHandler = (
@NonNull Request request,
@NonNull CorsPreflight corsPreflight,
@NonNull CorsPreflightResponse corsPreflightResponse
) -> {
// Return a MarshaledResponse with headers like
// Access-Control-Allow-Origin, Vary, etc.
};
// Preflight: the request is not allowed
CorsPreflightRejectedHandler corsPreflightRejectedHandler = (
@NonNull Request request,
@NonNull CorsPreflight corsPreflight
) -> {
// Return a MarshaledResponse with 403 or similar status
};
// Supplement responses for requests that have "passed" preflight
CorsAllowedHandler corsAllowedHandler = (
@NonNull Request request,
@NonNull Cors cors,
@NonNull CorsResponse corsResponse,
@NonNull MarshaledResponse marshaledResponse
) -> {
// Supplement the provided MarshaledResponse with
// Access-Control-Allow-Origin, Vary, etc. and return it
};
// Supply our custom handlers to the marshaler
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).responseMarshaler(
ResponseMarshaler.builder()
.resourceMethodHandler(...)
.corsPreflightAllowedHandler(corsPreflightAllowedHandler)
.corsPreflightRejectedHandler(corsPreflightRejectedHandler)
.corsAllowedHandler(corsAllowedHandler)
.build()
).build();
References:
CorsPreflightAllowedHandlerResponseMarshaler::forCorsPreflightAllowedCorsPreflightRejectedHandlerResponseMarshaler::forCorsPreflightRejectedCorsAllowedHandlerResponseMarshaler::forCorsAllowed
Full Customization
You may directly implement the ResponseMarshaler interface and provide it to Soklet as shown below. This is unusual; most applications are better served by providing "handler" functions to Soklet's internal implementation as outlined above.
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
// Bring your own totally-custom response marshaler
).responseMarshaler(new ResponseMarshaler() {
@NonNull
@Override
public MarshaledResponse forResourceMethod(
@NonNull Request request,
@NonNull Response response,
@NonNull ResourceMethod resourceMethod
) {
// Application response path
}
@NonNull
@Override
public MarshaledResponse forThrowable(
@NonNull Request request,
@NonNull Throwable throwable,
@Nullable ResourceMethod resourceMethod
) {
// Exception response path
}
// Other ResponseMarshaler methods elided
}
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
// Bring your own totally-custom response marshaler
).responseMarshaler(new ResponseMarshaler() {
@NonNull
@Override
public MarshaledResponse forResourceMethod(
@NonNull Request request,
@NonNull Response response,
@NonNull ResourceMethod resourceMethod
) {
// Application response path
}
@NonNull
@Override
public MarshaledResponse forThrowable(
@NonNull Request request,
@NonNull Throwable throwable,
@Nullable ResourceMethod resourceMethod
) {
// Exception response path
}
// Other ResponseMarshaler methods elided
}
References:
Redirects
Soklet provides special convenience shorthand for the following types of HTTP Redirect:
For the below examples, assume they will redirect to a GET /new Resource Method.
@GET("/new")
public String redirectTarget() {
return "Hello from /new!";
}
@GET("/new")
public String redirectTarget() {
return "Hello from /new!";
}
301 Moved Permanently
@GET("/test/301")
public Response test301() {
return Response.withRedirect(
RedirectType.HTTP_301_MOVED_PERMANENTLY, "/new"
).build();
}
@GET("/test/301")
public Response test301() {
return Response.withRedirect(
RedirectType.HTTP_301_MOVED_PERMANENTLY, "/new"
).build();
}
To test:
% curl -L -i 'http://localhost:8080/test/301'
HTTP/1.1 301 Moved Permanently
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Location: /new
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello from /new!
% curl -L -i 'http://localhost:8080/test/301'
HTTP/1.1 301 Moved Permanently
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Location: /new
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello from /new!
302 Found
@GET("/test/302")
public Response test302() {
return Response.withRedirect(
RedirectType.HTTP_302_FOUND, "/new"
).build();
}
@GET("/test/302")
public Response test302() {
return Response.withRedirect(
RedirectType.HTTP_302_FOUND, "/new"
).build();
}
To test:
% curl -L -i 'http://localhost:8080/test/302'
HTTP/1.1 302 Found
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Location: /new
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello from /new!
% curl -L -i 'http://localhost:8080/test/302'
HTTP/1.1 302 Found
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Location: /new
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello from /new!
303 See Other
@GET("/test/303")
public Response test303() {
return Response.withRedirect(
RedirectType.HTTP_303_SEE_OTHER, "/new"
).build();
}
@GET("/test/303")
public Response test303() {
return Response.withRedirect(
RedirectType.HTTP_303_SEE_OTHER, "/new"
).build();
}
To test:
% curl -L -i 'http://localhost:8080/test/303'
HTTP/1.1 303 See Other
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Location: /new
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello from /new!
% curl -L -i 'http://localhost:8080/test/303'
HTTP/1.1 303 See Other
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Location: /new
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello from /new!
307 Temporary Redirect
@GET("/test/307")
public Response test307() {
return Response.withRedirect(
RedirectType.HTTP_307_TEMPORARY_REDIRECT, "/new"
).build();
}
@GET("/test/307")
public Response test307() {
return Response.withRedirect(
RedirectType.HTTP_307_TEMPORARY_REDIRECT, "/new"
).build();
}
To test:
% curl -L -i 'http://localhost:8080/test/307'
HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Location: /new
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello from /new!
% curl -L -i 'http://localhost:8080/test/307'
HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Location: /new
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello from /new!
308 Permanent Redirect
@GET("/test/308")
public Response test308() {
return Response.withRedirect(
RedirectType.HTTP_308_PERMANENT_REDIRECT, "/new"
).build();
}
@GET("/test/308")
public Response test308() {
return Response.withRedirect(
RedirectType.HTTP_308_PERMANENT_REDIRECT, "/new"
).build();
}
To test:
% curl -L -i 'http://localhost:8080/test/308'
HTTP/1.1 308 Permanent Redirect
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Location: /new
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello from /new!
% curl -L -i 'http://localhost:8080/test/308'
HTTP/1.1 308 Permanent Redirect
Content-Length: 0
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Location: /new
HTTP/1.1 200 OK
Content-Length: 16
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Hello from /new!
Custom
It's not required to use Soklet's RedirectType shorthand for redirects - manually setting the HTTP status code and Location response header works just as well.
@GET("/test/307-custom")
public Response test307Custom() {
return Response.withStatusCode(307)
.headers(Map.of("Location", Set.of("/new")))
.build();
}
@GET("/test/307-custom")
public Response test307Custom() {
return Response.withStatusCode(307)
.headers(Map.of("Location", Set.of("/new")))
.build();
}

