Core Concepts
Value Conversions
Given an HTTP request...
GET /test?number=123
GET /test?number=123
...and a corresponding Resource Method...
@GET("/test")
public String test(@QueryParameter Integer number) {
return String.format("Hello %d", number);
}
@GET("/test")
public String test(@QueryParameter Integer number) {
return String.format("Hello %d", number);
}
...how does Soklet convert the query parameter number to an instance of java.lang.Integer?
The answer: it consults a ValueConverterRegistry which contains a set of ValueConverter<F,T>, each instance of which knows how to convert a "from" type F to a "to" type T.
Value conversions will be automatically applied to Resource Method parameters decorated with any of these annotations:
@QueryParameter@PathParameter@RequestCookie@RequestHeader@FormParameter@Multipart@RequestBody(only by default; you likely want to perform custom parsing usingRequestBodyMarshalerinstead)
For ordinary HTTP request parameter binding, Soklet supports more than just scalar T values. Query parameters, form parameters, request headers, request cookies, and multipart fields may also be injected as Optional<T>, List<T>, or Optional<List<T>>. Repeated values bind naturally to lists, while repeated values for non-list parameters are rejected instead of silently picking one.
The same ValueConverterRegistry is also used by SSE Event Source Methods. An @SseEventSource method uses the normal resource-method binding pipeline, so @PathParameter, @QueryParameter, @RequestHeader, and @RequestCookie values are converted exactly the same way they would be for a regular HTTP method.
The registry is also used by MCP endpoint and resource URI parameter binding. That includes annotation-based MCP parameters like @McpEndpointPathParameter and @McpUriParameter, along with typed context helpers such as context.getEndpointPathParameter("tenantId", UUID.class) and context.getUriParameter("recipeId", Integer.class). See MCP for the surrounding protocol details.
Default Conversions
Via ValueConverterRegistry, Soklet provides out-of-the-box support for the following conversions to JDK types:
String⇒Integer(and primitiveint)String⇒Long(and primitivelong)String⇒Double(and primitivedouble)String⇒Float(and primitivefloat)String⇒Byte(and primitivebyte)String⇒Short(and primitiveshort)String⇒Character(and primitivechar)String⇒Boolean(and primitiveboolean)String⇒BigIntegerString⇒BigDecimalString⇒NumberString⇒UUIDString⇒Date(ISO 8601, e.g.2025-10-02T14:05:10.973318Zor millis since Epoch, e.g.1708878162881)String⇒Instant(ISO 8601, e.g.2025-10-02T14:05:10.973318Zor millis since Epoch, e.g.1708878162881)String⇒LocalDate(ISO 8601, e.g.2024-02-25)String⇒LocalTime(ISO 8601, e.g.23:15or23:15:10)String⇒LocalDateTime(ISO 8601, e.g.2024-02-25T10:15:30)String⇒TimeZone(Olson TZ identifier, e.g.America/New_York)String⇒ZoneId(Olson TZ identifier, e.g.America/New_York)String⇒Locale(IETF BCP 47 language tag, e.g.pt-BR)String⇒Currency(ISO 4217 currency code, e.g.BRL)
The set of default converters is programmatically accessible via ValueConverters::defaultValueConverters.
There are two other quality-of-life features provided by ValueConverterRegistry:
A special reflexive
ValueConverter<F,T>is automatically used for scenarios in whichFis equal toT.For any
StringtoEnum<E extends Enum<E>>conversions, aValueConverter<F,T>is automatically generated if necessary and cached off. This is almost always what you want.
For example, given this declaration:
enum Testing {
ONE,
TWO,
THREE
}
enum Testing {
ONE,
TWO,
THREE
}
This code will "just work" without any additional setup:
@GET("/example")
public String example(@QueryParameter Testing testing) {
return String.format("Hello %s", testing.name());
}
@GET("/example")
public String example(@QueryParameter Testing testing) {
return String.format("Hello %s", testing.name());
}
At runtime, Soklet will automatically generate and cache a ValueConverter<F,T> that knows how to convert a String to Testing.ONE, Testing.TWO, or Testing.THREE so you do not have to manually create and register a custom converter.
Enum auto-conversion uses exact enum constant names via Enum.valueOf(...). VANILLA matches Flavor.VANILLA, while vanilla does not unless you register a custom converter.
Common Binding Shapes
These are all valid uses of Soklet value conversion for HTTP parameters:
@GET("/search")
public Response search(
@QueryParameter(name = "tag") List<String> tags,
@QueryParameter(name = "limit") Optional<Integer> limit,
@RequestHeader(name = "X-Tenant") Optional<UUID> tenantId
) {
// ...
}
@GET("/search")
public Response search(
@QueryParameter(name = "tag") List<String> tags,
@QueryParameter(name = "limit") Optional<Integer> limit,
@RequestHeader(name = "X-Tenant") Optional<UUID> tenantId
) {
// ...
}
For multipart forms, list and optional-list binding work too:
@POST("/upload")
public void upload(
@Multipart(name = "file") List<byte[]> files,
@Multipart(name = "score") Optional<List<Integer>> scores
) {
// ...
}
@POST("/upload")
public void upload(
@Multipart(name = "file") List<byte[]> files,
@Multipart(name = "score") Optional<List<Integer>> scores
) {
// ...
}
For MCP handlers, typed endpoint and URI parameter access uses the same conversion registry:
UUID tenantId = context.getEndpointPathParameter("tenantId", UUID.class)
.orElseThrow();
Integer recipeId = resourceContext.getUriParameter("recipeId", Integer.class)
.orElseThrow();
UUID tenantId = context.getEndpointPathParameter("tenantId", UUID.class)
.orElseThrow();
Integer recipeId = resourceContext.getUriParameter("recipeId", Integer.class)
.orElseThrow();
Blank Inputs and Whitespace
Soklet's default converters aggressively trim incoming string values before conversion. If you extend AbstractValueConverter or FromStringValueConverter, leading and trailing Unicode space-separator characters are removed first, and values made up entirely of those characters become null before your conversion logic runs.
This is meant to catch surprising Unicode spaces such as non-breaking spaces or narrow no-break spaces. It does not trim tabs, carriage returns, or line feeds.
That means:
- A scalar query/form/header/cookie value of
""or" "behaves like a missing value. - Blank entries inside repeated values are skipped when binding to
List<T>. - If you need different trimming behavior, override
shouldTrimFromValues()or implementValueConverterdirectly.
Blank-Slate Conversions
To acquire a strict registry without defaults, reflexive conversion, or automatic enum conversion, use the blank-slate factories:
// True blank slate: no conversions performed
ValueConverterRegistry registry = ValueConverterRegistry.fromBlankSlate();
// Blank slate plus only those converters you explicitly specify
ValueConverterRegistry supplemented =
ValueConverterRegistry.fromBlankSlateSupplementedBy(
Set.of(/* Custom ValueConverters here */)
);
// True blank slate: no conversions performed
ValueConverterRegistry registry = ValueConverterRegistry.fromBlankSlate();
// Blank slate plus only those converters you explicitly specify
ValueConverterRegistry supplemented =
ValueConverterRegistry.fromBlankSlateSupplementedBy(
Set.of(/* Custom ValueConverters here */)
);
Default @RequestBody Conversion
Soklet's default RequestBodyMarshaler is intentionally simple. It reads the raw request body as a string and then looks for a String -> T converter in the ValueConverterRegistry.
That is useful for plain-text payloads such as:
- Numbers
- Dates and times
- UUIDs
- Your own custom textual formats
It is not a general JSON object mapper.
If you want to parse JSON, XML, protobuf, or any other structured content type, supply your own RequestBodyMarshaler as described in Request Handling.
Custom Conversions
Suppose we want to accept a typed JWT, which is comprised of 3 Base64-URL-encoded segments separated by a . character, e.g. a.b.c. Let's create a record to represent it:
public record Jwt(
String header,
String payload,
String signature
) {}
public record Jwt(
String header,
String payload,
String signature
) {}
Next, we need to make a ValueConverter<String, Jwt>. We could directly implement the interface, but using Soklet's abstract convenience class FromStringValueConverter<T> takes care of the boilerplate and lets us focus solely on the conversion code:
ValueConverter<String, Jwt> jwtVc = new FromStringValueConverter<>() {
@NonNull
public Optional<Jwt> performConversion(@Nullable String from) throws Exception {
if(from == null)
return Optional.empty();
// JWT is of the form "a.b.c", break it into pieces
String[] components = from.split("\\.");
Jwt jwt = new Jwt(components[0], components[1], components[2]);
return Optional.of(jwt);
}
};
ValueConverter<String, Jwt> jwtVc = new FromStringValueConverter<>() {
@NonNull
public Optional<Jwt> performConversion(@Nullable String from) throws Exception {
if(from == null)
return Optional.empty();
// JWT is of the form "a.b.c", break it into pieces
String[] components = from.split("\\.");
Jwt jwt = new Jwt(components[0], components[1], components[2]);
return Optional.of(jwt);
}
};
Soklet now needs to know about our custom ValueConverter<String, Jwt>. We create a ValueConverterRegistry - which already is prefilled with default Value Converters - and provide it with our additional custom converter:
// Create a ValueConverterRegistry, supplemented with our custom converter
ValueConverterRegistry valueConverterRegistry =
ValueConverterRegistry.fromDefaultsSupplementedBy(Set.of(jwtVc));
// Configure Soklet to use our registry
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).valueConverterRegistry(valueConverterRegistry)
.build();
// Create a ValueConverterRegistry, supplemented with our custom converter
ValueConverterRegistry valueConverterRegistry =
ValueConverterRegistry.fromDefaultsSupplementedBy(Set.of(jwtVc));
// Configure Soklet to use our registry
SokletConfig config = SokletConfig.withHttpServer(
HttpServer.fromPort(8080)
).valueConverterRegistry(valueConverterRegistry)
.build();
Everything is in place. We can now accept our new Jwt type anywhere that Soklet injects values.
@GET("/jwt/{jwt}/payload")
public String jwtPayload(@PathParameter Jwt jwt) {
return jwt.payload();
}
@GET("/jwt/{jwt}/payload")
public String jwtPayload(@PathParameter Jwt jwt) {
return jwt.payload();
}
Verify that it works:
% curl -i 'http://localhost:8080/jwt/a.b.c/payload'
HTTP/1.1 200 OK
Content-Length: 1
Content-Type: text/plain;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
b
% curl -i 'http://localhost:8080/jwt/a.b.c/payload'
HTTP/1.1 200 OK
Content-Length: 1
Content-Type: text/plain;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
b
And verify behavior if conversion fails:
% curl -i 'http://localhost:8080/jwt/abc/payload'
HTTP/1.1 400 Bad Request
Content-Length: 21
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
HTTP 400: Bad Request
% curl -i 'http://localhost:8080/jwt/abc/payload'
HTTP/1.1 400 Bad Request
Content-Length: 21
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
HTTP 400: Bad Request
We can see that if an exception occurs during value conversion, Soklet detects this and re-throws a subclass of BadRequestException with a detailed description of the problem and fields that contain the offending name and value (in this case, IllegalPathParameterException). Your exception handling code will have everything it needs to gracefully process the error and surface a user-friendly error message.
com.soklet.exception.IllegalPathParameterException: Illegal value 'abc' was specified for path parameter 'jwt' (was expecting a value convertible to class com.soklet.example.Jwt)
at com.soklet.DefaultResourceMethodParameterProvider.extractParameterValueToPassToResourceMethod(DefaultResourceMethodParameterProvider.java:164)
...
Caused by: com.soklet.converter.ValueConversionException: Unable to convert value 'abc' of type class java.lang.String to an instance of class com.soklet.example.Jwt
...
Caused by: java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
at com.soklet.example.AppModule$1.performConversion(AppModule.java:120)
at com.soklet.example.AppModule$1.performConversion(AppModule.java:116)
at com.soklet.converter.AbstractValueConverter.convert(AbstractValueConverter.java:96)
com.soklet.exception.IllegalPathParameterException: Illegal value 'abc' was specified for path parameter 'jwt' (was expecting a value convertible to class com.soklet.example.Jwt)
at com.soklet.DefaultResourceMethodParameterProvider.extractParameterValueToPassToResourceMethod(DefaultResourceMethodParameterProvider.java:164)
...
Caused by: com.soklet.converter.ValueConversionException: Unable to convert value 'abc' of type class java.lang.String to an instance of class com.soklet.example.Jwt
...
Caused by: java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
at com.soklet.example.AppModule$1.performConversion(AppModule.java:120)
at com.soklet.example.AppModule$1.performConversion(AppModule.java:116)
at com.soklet.converter.AbstractValueConverter.convert(AbstractValueConverter.java:96)
Generic Type Lookups
If you interact with ValueConverterRegistry directly and need a converter for a generic type, use TypeReference<T>. Java cannot express something like List<Integer>.class, so the type token has to carry that generic information.
Optional<ValueConverter<String, List<Integer>>> converter =
registry.get(
new TypeReference<String>() {},
new TypeReference<List<Integer>>() {}
);
Optional<ValueConverter<String, List<Integer>>> converter =
registry.get(
new TypeReference<String>() {},
new TypeReference<List<Integer>>() {}
);
Limitations
Soklet infers converter generic types using reflection and currently only supports direct subclasses of AbstractValueConverter or FromStringValueConverter. If you introduce intermediate generic base classes, inference may fail.
For example, this can break:
abstract class BadConverter<T> extends AbstractValueConverter<String, T> {}
class JwtConverter extends BadConverter<Jwt> {
@Override
public Optional<Jwt> performConversion(@Nullable String from) throws Exception {
// ...
return Optional.empty();
}
}
abstract class BadConverter<T> extends AbstractValueConverter<String, T> {}
class JwtConverter extends BadConverter<Jwt> {
@Override
public Optional<Jwt> performConversion(@Nullable String from) throws Exception {
// ...
return Optional.empty();
}
}
In this scenario Soklet can throw an IllegalStateException when it cannot resolve the F/T types. The workaround is to implement ValueConverter directly or extend AbstractValueConverter/FromStringValueConverter directly in the concrete converter class.
This limitation may be addressed in a future release.

