Maven Central CI Javadoc Changelog

What Is It?

A small HTTP/1.1 server and route handler for Java, well-suited for building RESTful APIs, broadcasting Server-Sent Events, and providing Model Context Protocol (MCP) functionality. Zero dependencies. Dependency Injection friendly. Optionally powered by JEP 444: Virtual Threads.

Soklet codes like a library, not a framework.

Note: this README provides a high-level overview of Soklet. For details, please refer to the official documentation at https://www.soklet.com.

Why?

The Java web ecosystem is missing an HTTP server solution that is dependency-free but offers support for Server-Sent Events (SSE) and Model Context Protocol (MCP) along with hooks for dependency injection and annotation-based request handling. Soklet aims to fill this void.

Soklet provides the plumbing to build "transactional" REST APIs as well as agentic systems that vend results via HTTP response streaming or SSE, and expose tools, prompts, and resources via MCP. It does not make technology choices on your behalf (but an example of how to build a full-featured API is available). It does not natively support Reactive Programming or similar methodologies. It does give you the foundation to build your system, your way.

Soklet is commercially-friendly Open Source Software, proudly powering production systems since 2015.

Design Goals

Design Non-Goals

  • SSL/TLS (your load balancer should provide TLS termination)
  • HTTP/2, HTTP/3 (also handled by your load balancer)
  • WebSockets
  • Dictate which technologies to use (Guice vs. Dagger, Gson vs. Jackson, etc.)
  • "Batteries included" authentication and authorization

Do Zero-Dependency Libraries Interest You?

Similarly-flavored commercially-friendly OSS libraries are available.

  • Pyranid - makes working with JDBC pleasant
  • Lokalized - natural-sounding translations (i18n) via expression language

License

Apache 2.0

Installation

Soklet is a single JAR, available on Maven Central.

JDK 17+ is required (or JDK 21+ for Server-Sent Events and MCP).

Maven

<dependency>
  <groupId>com.soklet</groupId>
  <artifactId>soklet</artifactId>
  <version>3.5.1</version>
</dependency>

Gradle

repositories {
  mavenCentral()
}

dependencies {
  implementation 'com.soklet:soklet:3.5.1'
}

Direct Download

If you don't use Maven or Gradle, you can drop soklet-3.5.1.jar directly into your project. No other dependencies are required.

Code Sample

Here we demonstrate building and running a single-file Soklet application with nothing but the soklet-3.5.1.jar and the JDK. There are no other libraries or frameworks, no Servlet container, no Maven or Gradle build process - no special setup is required.

Soklet systems can be structurally as simple as a "hello world" app.

While a real production system will have more moving parts, this demonstrates that you can build server software without ceremony or dependencies.

package com.soklet.example;

public class App {
  // Canonical example
  @GET("/")
  public String index() {
    return "Hello, world!";
  }

  // Echoes back the path parameter, which must be a LocalDate
  @GET("/echo/{date}")
  public LocalDate echo(@PathParameter LocalDate date) {
    return date;
  }

  // Formats request body locale for display and customizes the response.
  // Example: fr-CA ⇒ francês (Canadá)
  @POST("/language")
  public Response languageFor(@RequestBody Locale locale) {
    Locale systemLocale = Locale.forLanguageTag("pt-BR");
    String contentLanguage = systemLocale.toLanguageTag();

    return Response.withStatusCode(200)
      .body(locale.getDisplayName(systemLocale))
      .headers(Map.of("Content-Language", Set.of(contentLanguage)))
      .cookies(Set.of(
        ResponseCookie.withName("lastRequest")
          .value(Instant.now().toString())
          .httpOnly(true)
          .secure(true)
          .maxAge(Duration.ofMinutes(5))
          .sameSite(SameSite.LAX)
          .build()
      ))
      .build();
  }

  // Start the server and listen on :8080
  public static void main(String[] args) throws Exception {
    // Use out-of-the-box defaults
    SokletConfig config = SokletConfig.withHttpServer(
      HttpServer.fromPort(8080)
    ).build();

    try (Soklet soklet = Soklet.fromConfig(config)) {
      soklet.start();
      System.out.println("Soklet started, press [enter] to exit");
      soklet.awaitShutdown(ShutdownTrigger.ENTER_KEY);
    }
  }
}

Here we use raw javac to build and java to run.

This example requires JDK 17+ to be installed on your machine (or see this example of using Docker for Soklet apps). If you need a JDK, Amazon provides Corretto - a free-to-use-commercially, production-ready distribution of OpenJDK that includes long-term support.

Build

javac -parameters -cp soklet-3.5.1.jar -processor com.soklet.SokletProcessor -d build src/com/soklet/example/App.java

Run

java -cp soklet-3.5.1.jar:build com/soklet/example/App

Test

# Hello, world
% curl -i 'http://localhost:8080/'
HTTP/1.1 200 OK
Content-Length: 13
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT

Hello, world!
# Acceptable path parameter
% curl -i 'http://localhost:8080/echo/2024-12-31'
HTTP/1.1 200 OK
Content-Length: 10
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT

2024-12-31
# Illegal path parameter
% curl -i 'http://localhost:8080/echo/abc'
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
# Language request body
% curl -i -X POST 'http://localhost:8080/language' -d 'fr-CA'
HTTP/1.1 200 OK
Content-Language: pt-BR
Content-Length: 18
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Set-Cookie: lastRequest=2024-04-21T16:19:01.115336Z; Max-Age=300; Secure; HttpOnly; SameSite=Lax

francês (Canadá)

Building Real-World Apps

Of course, real-world apps have more moving parts than a "hello world" example.

The Toy Store App showcases how you might build a robust production system with Soklet.

Feature highlights include:

What Else Does It Do?

Request Handling

Soklet maps HTTP requests to plain Java methods known as Resource Methods (ResourceMethod). Annotate them with @GET, @POST, @PUT, @PATCH, @DELETE, @HEAD, @OPTIONS, or @SseEventSource for SSE. MCP endpoints are declared separately with @McpServerEndpoint and handler annotations like @McpTool, @McpPrompt, and @McpResource. Soklet discovers them at compile time via the SokletProcessor annotation processor, avoiding classpath scans at startup. See the Request Handling docs for details.

Access To Request Data

Resource Methods (ResourceMethod) can accept a Request parameter and inspect HttpMethod values.

@GET("/example")
public void example(Request request /* param name is arbitrary */) {
  // Here, it would be HttpMethod.GET
  HttpMethod httpMethod = request.getHttpMethod();
  // Just the path, e.g. "/example"
  String path = request.getPath();
  // The raw path and query, e.g. "/example?test=123"
  String rawPathAndQuery = request.getRawPathAndQuery();
  // Request body as bytes, if available
  Optional<byte[]> body = request.getBody();
  // Request body marshaled to a string, if available.
  // Charset defined in "Content-Type" header is used to marshal.
  // If not specified, UTF-8 is assumed
  Optional<String> bodyAsString = request.getBodyAsString();
  // Query parameter values by name
  Map<String, Set<String>> queryParameters = request.getQueryParameters();
  // Shorthand for plucking the first query param value by name
  Optional<String> queryParameter = request.getQueryParameter("test");
  // Header values by name (names are case-insensitive)
  Map<String, Set<String>> headers = request.getHeaders();
  // Shorthand for plucking the first header value by name (case-insensitive)
  Optional<String> header = request.getHeader("Accept-Language");
  // Parsed W3C trace context from traceparent/tracestate, if present
  Optional<TraceContext> traceContext = request.getTraceContext();
  // Request cookies by name (names are case-insensitive)
  Map<String, Set<String>> cookies = request.getCookies();
  // Shorthand for plucking the first cookie value by name (case-insensitive)
  Optional<String> cookie = request.getCookie("cookie-name");
  // Form parameters by name (application/x-www-form-urlencoded)
  Map<String, Set<String>> fps = request.getFormParameters();
  // Shorthand for plucking the first form parameter value by name
  Optional<String> fp = request.getFormParameter("fp-name");
  // Is this a multipart request?
  boolean multipart = request.isMultipart();
  // Multipart fields by name
  Map<String, Set<MultipartField>> mpfs = request.getMultipartFields();
  // Shorthand for plucking the first multipart field by name
  Optional<MultipartField> mpf = request.getMultipartField("file-input");
  // CORS information, if available
  Optional<Cors> cors = request.getCors();
  // Ordered locales via Accept-Language parsing
  List<Locale> locales = request.getLocales();
  // Ordered media ranges via Accept parsing; empty means no Accept preference
  List<MediaRange> mediaRanges = request.getMediaRanges();
  // Charset as specified by "Content-Type" header, if available
  Optional<Charset> charset = request.getCharset();
  // Content type component of "Content-Type" header, if available
  Optional<String> contentType = request.getContentType();
}

Value Conversions

Soklet converts textual request inputs to Java types using a ValueConverterRegistry populated with ValueConverter<F,T>. Conversions are applied to parameters annotated with @QueryParameter, @PathParameter, @RequestHeader, @RequestCookie, @FormParameter, and @Multipart. Supply your own registry (or additional converters) via SokletConfig to support custom types.

Request Body Parsing

Configure a RequestBodyMarshaler however you like - here we accept JSON:

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).requestBodyMarshaler(new RequestBodyMarshaler() {
  // This example uses Google's GSON
  static final Gson GSON = new Gson();

  @NonNull
  @Override
  public Optional<Object> marshalRequestBody(
    @NonNull Request request,
    @NonNull ResourceMethod resourceMethod,
    @NonNull Parameter parameter,
    @NonNull Type requestBodyType
  ) {
    // Let GSON turn the request body into an instance
    // of the specified type.
    //
    // Note that this method has access to all runtime information
    // about the request, which provides the opportunity to, for example,
    // examine annotations on the method/parameter which might
    // inform custom marshaling strategies.
    return Optional.of(GSON.fromJson(
      request.getBodyAsString().orElseThrow(),
      requestBodyType
    ));
  }
}).build();

Then, apply:

public record Employee (
  UUID id,
  String name
) {}

// Accepts a JSON-formatted Record type as input
@POST("/employees")
public void createEmployee(@RequestBody Employee employee) {
  System.out.printf("TODO: create %s\n", employee.name());
}

Response Writing

To control how response data is surfaced to clients (e.g. JSON), provide handler functions (ResourceMethodHandler and ThrowableHandler) to Soklet as shown below.

Alternatively, you can provide your own implementation of ResponseMarshaler for full control.

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

// 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, 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 handlers to the standard response marshaler
SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).responseMarshaler(ResponseMarshaler.builder()
  .resourceMethod(resourceMethodHandler)
  .throwable(throwableHandler)
  .build()
).build();
Zero-Copy Responses

Already know exactly what you want to send over the wire? Use MarshaledResponse to skip additional processing.

@GET("/example-image.png")
public MarshaledResponse exampleImage() {
  Path imageFile = Path.of("/home/user/test.png");

  // Serve a known-length file response over the wire.
  // Soklet sets Content-Length from the file size; Content-Type remains explicit.
  return MarshaledResponse.withStatusCode(200)
    .body(imageFile)
    .headers(Map.of(
      "Content-Type", Set.of("image/png")
    ))
    .build();
}

MarshaledResponse supports known-length byte-array, file, file-channel, and ByteBuffer bodies. The standard HTTP server can write file-backed responses without first loading the whole file into heap memory. If you already selected a trusted file and want file-response semantics like validators and byte ranges, use MarshaledResponse::withFile; its builder can set Content-Type, Content-Encoding, cache headers, validators, and range behavior. For safe static roots, use StaticFiles instead of hand-rolled path joins; it handles root containment, validators, optional content-hash ETags, access policy, single byte ranges, MIME defaults, and GET/HEAD behavior. Standard HTTP can also opt into gzip compression for finalized in-memory byte-array and ByteBuffer responses with HttpServer.Builder::responseGzipPolicy, including ResponseGzipPolicy::fromDefaultsWithMinimumBodySizeInBytes for common text-like response media types.

Streaming Responses

MarshaledResponse also supports streaming response bodies when the final byte length is not known up front. Streaming is intentionally a marshaled-response feature, like file-backed output: the resource method is taking direct control of what Soklet writes to the HTTP response.

@GET("/tokens")
public MarshaledResponse tokens(TokenService tokenService) {
  return MarshaledResponse.withStatusCode(200)
    .headers(Map.of(
      "Content-Type", Set.of("text/plain; charset=UTF-8"),
      "Cache-Control", Set.of("no-transform")
    ))
    .stream(StreamingResponseBody.fromWriter((output, context) -> {
      try (AutoCloseable ignored = context.onCancel(tokenService::stop)) {
        tokenService.generate(token -> {
          context.throwIfCanceled();
          output.write(token.getBytes(StandardCharsets.UTF_8));
          output.flush();
        });
      }
    }))
    .build();
}

Streaming responses use HTTP/1.1 chunked transfer encoding. Soklet owns Transfer-Encoding, rejects caller-supplied Content-Length, and gives the producer a StreamingResponseContext so upstream work can be canceled when the client disconnects, the server shuts down, or a streaming timeout fires. That context also exposes the originating Request, so producers can use Request::getId for correlation without ambient thread-local state.

Redirects (via Response):

@GET("/example-redirect")
public Response exampleRedirect() {
  // Response has a convenience builder for performing redirects.
  // You could alternatively do this "by hand" by setting HTTP status
  // and headers appropriately.
  return Response.withRedirect(
    RedirectType.HTTP_307_TEMPORARY_REDIRECT, "/other-url"
  ).build();
}

HTTP Server Configuration

Soklet ships with an embedded HTTP/1.1 HttpServer, a dedicated SseServer, and a dedicated McpServer. These builders let you configure host, read/write/handler timeouts, handler concurrency/queueing, request size limits, request decompression, and connection caps; you can also plug in custom request/session IdGenerator, McpSessionStore, and MultipartParser instances. Standard HTTP request-body decompression is disabled by default; enable HttpServer.Builder::requestDecompressionPolicy with RequestDecompressionPolicy::fromDefaults or a custom policy to accept single-coding Content-Encoding: gzip/x-gzip request bodies with decompression-bomb limits. Handlers receive the decompressed bytes through Request::getBody, while Request::getEncodedBodySizeInBytes retains the pre-decompression payload size for telemetry. Configure MCP session ID generation and session caps on the default McpSessionStore builder, or let a custom store own those policies. Provide the configured servers via SokletConfig and see the Server Configuration docs for the full option matrix.

Server-Sent Events (SSE)

SSE endpoints are declared with @SseEventSource and return a SseHandshakeResult, served from a dedicated SseServer port (separate from your standard HTTP server port).

public record ChatMessage(String message) {}

public class ChatResource {
  @SseEventSource("/chat")
  public SseHandshakeResult chat() {
    return SseHandshakeResult.Accepted.builder()
      .clientInitializer(unicaster -> {
        unicaster.unicastEvent(SseEvent.withEvent("hello")
          .data("welcome")
          .build());
      })
      .build();
  }

  @POST("/chat")
  public void postMessage(@RequestBody ChatMessage message,
                          @NonNull SseServer sseServer) {
   @NonNull SseBroadcaster broadcaster = sseServer
      .acquireBroadcaster(ResourcePath.fromPath("/chat"))
      .orElseThrow();

    broadcaster.broadcastEvent(SseEvent.withEvent("message")
      .data(message.message())
      .build());
  }
}

Because this example exposes both an SSE event source and a regular POST /chat resource method, it needs both servers:

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).sseServer(
  SseServer.fromPort(8081)
).resourceMethodResolver(
  ResourceMethodResolver.fromClasses(Set.of(ChatResource.class))
).build();

If your application only exposes SSE event source methods, you can omit the regular HTTP server and start with SokletConfig::withSseServer instead.

SSE test via the Simulator (see SseRequestResult):

import org.junit.Assert;
import org.junit.Test;

@Test
public void sseTest() {
  SokletConfig config = SokletConfig.withHttpServer(HttpServer.fromPort(0).build())
    .sseServer(SseServer.fromPort(0))
    .resourceMethodResolver(ResourceMethodResolver.fromClasses(Set.of(ChatResource.class)))
    .build();

  List<SseEvent> events = new ArrayList<>();

  Soklet.runSimulator(config, simulator -> {
    Request request = Request.fromPath(HttpMethod.GET, "/chat");
   @NonNull SseRequestResult result = simulator.performSseRequest(request);

    if (result instanceof SseRequestResult.HandshakeAccepted accepted) {
      accepted.registerEventConsumer(events::add);

     @NonNull SseBroadcaster broadcaster = config.getSseServer().orElseThrow()
        .acquireBroadcaster(ResourcePath.fromPath("/chat")).orElseThrow();
      broadcaster.broadcastEvent(SseEvent.withEvent("message")
        .data("hello")
        .build());
    } else {
      throw new IllegalStateException("SSE handshake failed: " + result);
    }
  });

  Assert.assertEquals("hello", events.get(0).getData().orElse(null));
}

Model Context Protocol (MCP)

MCP endpoints are declared with @McpServerEndpoint and expose handlers via @McpTool, @McpPrompt, and @McpResource. They are served from a dedicated McpServer port, while Soklet manages MCP session lifecycle, JSON-RPC transport, SSE stream establishment, and simulator support.

@McpServerEndpoint(
  path = "/catalog/mcp",
  name = "catalog",
  version = "1.0.0",
  title = "Catalog MCP"
)
public class CatalogMcpEndpoint implements McpEndpoint {
  @Override
  public McpSessionContext initialize(McpInitializationContext context,
                                      McpSessionContext session) {
    return session.with("tenantId", "acme");
  }

  @McpTool(name = "lookup_recipe", description = "Looks up a recipe.")
  public McpToolResult lookupRecipe(@McpArgument("recipeId") String recipeId,
                                    McpSessionContext sessionContext) {
    String tenantId = sessionContext.get("tenantId", String.class).orElseThrow();

    return McpToolResult.builder()
      .content(McpTextContent.fromText(
        "Recipe %s for tenant %s".formatted(recipeId, tenantId)
      ))
      .build();
  }
}

Wire up an MCP-only app:

SokletConfig config = SokletConfig.withMcpServer(
  McpServer.withPort(8082)
    .handlerResolver(McpHandlerResolver.fromClasses(Set.of(CatalogMcpEndpoint.class)))
    .build()
).build();

If the same application also serves ordinary HTTP resource methods, add .httpServer(HttpServer.fromPort(8080)) to the builder.

Browser-based MCP clients can be enabled with McpCorsAuthorizer:

SokletConfig config = SokletConfig.withMcpServer(
  McpServer.withPort(8082)
    .handlerResolver(McpHandlerResolver.fromClasses(Set.of(CatalogMcpEndpoint.class)))
    .corsAuthorizer(McpCorsAuthorizer.fromWhitelistedOrigins(
      Set.of("https://chat.openai.com"),
      origin -> true
    ))
    .build()
).build();

That enables OPTIONS preflight handling plus Access-Control-* response headers on MCP POST / GET / DELETE responses for the configured origins. It also authorizes MCP transport requests that carry an Origin header; if Origin is present and the configured McpCorsAuthorizer does not authorize it, Soklet rejects the request with HTTP 403 before running MCP endpoint code or mutating session/stream state. The default nonBrowserClientsOnlyInstance() remains conservative: non-browser clients that omit Origin work normally, while browser-originated MCP transport requests are rejected unless you explicitly allow the origin.

Soklet's MCP v1 support is intentionally conservative: single-request JSON-RPC only, framework-managed initialize, notifications/initialized, notifications/cancelled, and ping, cooperative in-flight request cancelation through McpCancelationToken, framework-generated tools/list / prompts/list / resources/templates/list responses without cursor pagination, and application-backed pagination only for resources/list. JSON-RPC batch arrays and resumable SSE event IDs remain deferred.

MCP test via the Simulator (see McpRequestResult):

import org.junit.Assert;
import org.junit.Test;

@Test
public void mcpTest() {
  SokletConfig config = SokletConfig.withMcpServer(McpServer.withPort(0)
    .handlerResolver(McpHandlerResolver.fromClasses(Set.of(CatalogMcpEndpoint.class)))
    .build()
  ).build();

  Soklet.runSimulator(config, simulator -> {
    McpRequestResult initializeResult = simulator.performMcpRequest(
      Request.withPath(HttpMethod.POST, "/catalog/mcp")
        .headers(Map.of("Content-Type", Set.of("application/json")))
        .body("""
          {
            "jsonrpc":"2.0",
            "id":"req-1",
            "method":"initialize",
            "params":{
              "protocolVersion":"2025-11-25",
              "capabilities":{},
              "clientInfo":{"name":"test-client","version":"1.0.0"}
            }
          }
          """.getBytes(StandardCharsets.UTF_8))
        .build()
    );

    if (!(initializeResult instanceof McpRequestResult.ResponseCompleted initializeResponse))
      throw new IllegalStateException("Expected initialize to complete without opening a stream");

    String sessionId = initializeResponse.getHttpRequestResult().getMarshaledResponse()
      .getHeaders().get("MCP-Session-Id").iterator().next();

    Assert.assertNotNull(sessionId);
  });
}

Form Handling

Frontend:

<form
  enctype="application/x-www-form-urlencoded"
  action="https://example.soklet.com/form?id=123"
  method="POST"
>
  <!-- User can type whatever text they like -->
  <input type="number" name="numericValue" />
  <!-- Multiple values for the same name are supported -->
  <input type="hidden" name="multi" value="1" />
  <input type="hidden" name="multi" value="2" />
  <!-- Names with special characters can be remapped -->
  <textarea name="long-text"></textarea>
  <!-- Note: browsers send "on" string to indicate "checked" -->
  <input type="checkbox" name="enabled" />
  <input type="submit" />
</form>

Backend:

Backend parameters can use @QueryParameter and @FormParameter.

@POST("/form")
public String form(
  @QueryParameter Long id,
  @FormParameter Integer numericValue,
  @FormParameter(optional=true) List<String> multi,
  @FormParameter(name="long-text") String longText,
  @FormParameter String enabled
) {
  // Echo back the inputs
  return List.of(id, numericValue, multi, longText, enabled).stream()
    .map(Object::toString)
    .collect(Collectors.joining("\n"));
}

Test:

% curl -i -X POST 'https://example.soklet.com/form?id=123' \
   -H 'Content-Type: application/x-www-form-urlencoded' \
   -d 'numericValue=456&multi=1&multi=2&long-text=long%20multiline%20text&enabled=on'
HTTP/1.1 200 OK
Content-Length: 37
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT

123
456
[1, 2]
long multiline text
on

Multipart Handling

Frontend:

<form
  enctype="multipart/form-data"
  action="https://example.soklet.com/multipart?id=123"
  method="POST"
>
  <!-- User can type whatever text they like -->
  <input type="text" name="freeform" />
  <!-- Multiple values for the same name are supported -->
  <input type="hidden" name="multi" value="1" />
  <input type="hidden" name="multi" value="2" />
  <!-- Prompt user to upload a file -->
  <p>Please attach your document: <input name="doc" type="file" /></p>
  <!-- Multiple file uploads are supported -->
  <p>
    Supplement 1: <input name="extra" type="file" /> Supplement 2:
    <input name="extra" type="file" />
  </p>
  <!-- An optional file -->
  <p>Optionally, attach a photo: <input name="photo" type="file" /></p>
  <input type="submit" value="Upload" />
</form>

Backend:

Backend parameters can use @Multipart and MultipartField.

@POST("/multipart")
public Response multipart(
  @QueryParameter Long id,
  // Multipart fields work like other Soklet params
  // with support for Optional<T>, List<T>, custom names, ...
  @Multipart(optional=true) String freeform,
  @Multipart(name="multi") List<Integer> numbers,
  // The MultipartField type allows access to additional data,
  // like filename and content type (if available).
  // The @Multipart annotation is optional
  // when your parameter is of type MultipartField...
  MultipartField document,
  // ...but is useful if you need to massage the name.
  @Multipart(name="extra") List<MultipartField> supplements,
  // If you specify type byte[] for a @Multipart field,
  // you'll get just its binary data injected
  @Multipart(optional=true) byte[] photo
) {
  // Let's demonstrate the functionality MultipartField provides.

  // Form field name, always available, e.g. "document"
  String name = document.getName();
  // Browser may provide this for files, e.g. "test.pdf"
  Optional<String> filename = document.getFilename();
  // Browser may provide this for files, e.g. "application/pdf"
  Optional<String> contentType = document.getContentType();
  // Field data as bytes, if available
  Optional<byte[]> data = document.getData();
  // Field data as a string, if available
  Optional<String> dataAsString = document.getDataAsString();

  // Apply the standard redirect-after-POST pattern
  return Response.withRedirect(
    RedirectType.HTTP_307_TEMPORARY_REDIRECT, "/thanks"
  ).build();
}

Dependency Injection

In practice, you will likely want to tie in to whatever Dependency Injection library your application uses and have the DI infrastructure vend your instances.

Soklet integrates via an InstanceProvider.

Here's how it might look if you use Google Guice:

// Standard Guice setup
Injector injector = Guice.createInjector(new MyExampleAppModule());

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).instanceProvider(new InstanceProvider() {
  @NonNull
  @Override
  public <T> T provide(@NonNull Class<T>