Soklet Logo

Core Concepts

Value Conversions

Given an HTTP request...

GET /test?number=123

...and a corresponding Resource Method...

@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:

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:

The set of default converters is programmatically accessible via ValueConverters::defaultValueConverters.

There are two other quality-of-life features provided by ValueConverterRegistry:

  1. A special reflexive ValueConverter<F,T> is automatically used for scenarios in which F is equal to T.

  2. For any String to Enum<E extends Enum<E>> conversions, a ValueConverter<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
}

This code will "just work" without any additional setup:

@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
) {
  // ...
}

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
) {
  // ...
}

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();

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 implement ValueConverter directly.

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 */)
  );

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
) {}

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);
  }
};

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();

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();
}

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

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

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)

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>>() {}
  );

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();
  }
}

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.

Previous
Instance Creation