soklet

mcp
Guvenlik Denetimi
Uyari
Health Gecti
  • License — License: Apache-2.0
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Community trust — 25 GitHub stars
Code Uyari
  • Code scan incomplete — No supported source files were scanned during light audit
Permissions Gecti
  • Permissions — No dangerous permissions requested
Purpose
This is a zero-dependency Java library that functions as an HTTP/1.1 and Server-Sent Event (SSE) server. It is designed to help developers build RESTful APIs and expose tools, prompts, and resources via the Model Context Protocol (MCP) for agentic systems.

Security Assessment
Overall Risk: Low. The tool operates as a local HTTP server, meaning it accepts incoming network connections rather than making outbound requests. Because it explicitly functions as a plumbing library with zero external dependencies, the attack surface is minimal. The automated scan noted that no source files were analyzed during the light audit (typical for Java projects in automated repo scanners), but a manual review of the README confirms it does not request dangerous permissions, execute shell commands, or require hardcoded secrets. Developers are expected to implement their own authentication and TLS termination via a load balancer.

Quality Assessment
The project appears to be highly maintained and professionally structured. It uses the commercially-friendly Apache-2.0 license and was updated very recently (last push was today). While it has a smaller community footprint with 25 GitHub stars, it claims to have been reliably powering production systems since 2015. The design philosophy centers on immutability, thread safety, and a small, comprehensible codebase, which are strong indicators of reliable software.

Verdict
Safe to use.
SUMMARY

Soklet is a zero-dependency Java HTTP/1.1 and Server-Sent Event + MCP server, well-suited for building RESTful APIs and tool-backed agentic systems.

README.md
Soklet

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 a server solution that is dependency-free but offers support for virtual threads, hooks for dependency injection, and annotation-based request handling. Soklet aims to fill this void.

Soklet provides the plumbing to build "transactional" REST APIs that exchange small amounts of data with clients.
It is well-suited for building tool-backed agentic systems that stream results via SSE or 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

  • Main focus: routing HTTP/1.1 requests to Java methods
  • Near-instant startup
  • Zero dependencies
  • Immutability/thread-safety
  • Small, comprehensible codebase
  • Support for automated unit and integration testing
  • Emphasis on configurability
  • Thorough, high-quality documentation
  • Best-in-class support for Server-Sent Events
  • First-class support for Model Context Protocol (MCP)
  • Servlet Integration for legacy code

Design Non-Goals

  • SSL/TLS (your load balancer should provide TLS termination)
  • Traditional HTTP streaming
  • 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).

Maven

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

Gradle

repositories {
  mavenCentral()
}

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

Direct Download

If you don't use Maven or Gradle, you can drop soklet-2.1.3.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-2.1.3.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-2.1.3.jar -processor com.soklet.SokletProcessor -d build src/com/soklet/example/App.java

Run

java -cp soklet-2.1.3.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");
  // 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();
  // 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();

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

@GET("/example-image.png")
public MarshaledResponse exampleImage() throws IOException {
  Path imageFile = Path.of("/home/user/test.png");
  byte[] image = Files.readAllBytes(imageFile);

  // Serve "final" bytes over the wire
  return MarshaledResponse.withStatusCode(200)
    .headers(Map.of(
      "Content-Type", Set.of("image/png"),
      "Content-Length", Set.of(String.valueOf(image.length))
    ))
    .body(image)
    .build();
}

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, timeouts, handler concurrency/queueing, request size limits, and connection caps; you
can also plug in custom IdGenerator,
McpSessionStore, and
MultipartParser instances.
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. The default
nonBrowserClientsOnlyInstance() remains conservative and keeps browser CORS disabled.

Soklet's MCP v1 support is intentionally conservative: single-request JSON-RPC only, 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> instanceClass) {
    // Have Soklet ask the Guice Injector for the instance
    return injector.getInstance(instanceClass);
  }
}).build();

Now, your Resources are dependency-injected just like the rest of your application is:

public class WidgetResource {
  private WidgetService widgetService;

  @Inject
  public WidgetResource(WidgetService widgetService) {
    this.widgetService = widgetService;
  }

  @GET("/widgets")
  public List<Widget> widgets() {
    return widgetService.findWidgets();
  }
}

Lifecycle Handling and Interception

Implement LifecycleObserver and
RequestInterceptor to hook into server and request lifecycles.

HTTP Server Start/Stop: execute code immediately before and after HttpServer startup and shutdown.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willStartHttpServer(@NonNull HttpServer httpServer) {
    // Perform startup tasks required prior to server launch
    MyPayrollSystem.INSTANCE.startLengthyWarmupProcess();
  }

  @Override
  public void didStartHttpServer(@NonNull HttpServer httpServer) {
    // HTTP server has fully started up and is listening
    System.out.println("HTTP server started.");
  }

  @Override
  public void willStopHttpServer(@NonNull HttpServer httpServer) {
    // Perform shutdown tasks required prior to server teardown
    MyPayrollSystem.INSTANCE.destroy();
  }

  @Override
  public void didStopHttpServer(@NonNull HttpServer httpServer) {
    // HTTP server has fully shut down
    System.out.println("HTTP server stopped.");
  }
}).build();

Request Handling: these methods are fired at the very start of Request processing and the very end, respectively.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void didStartRequestHandling(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod
  ) {
    System.out.printf("Received request: %s\n", request);

    // If there was no resourceMethod matching the request, expect a 404
    if(resourceMethod != null)
      System.out.printf("Request to be handled by: %s\n", resourceMethod);
    else
      System.out.println("This will be a 404.");
  }

  @Override
  public void didFinishRequestHandling(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull MarshaledResponse marshaledResponse,
    @NonNull Duration processingDuration,
    @NonNull List<Throwable> throwables
  ) {
    // We have access to a few things here...
    // * marshaledResponse is what was ultimately sent
    //    over the wire
    // * processingDuration is how long everything took,
    //    including sending the response to the client
    // * throwables is the ordered list of exceptions
    //    thrown during execution (if any)
    long millis = processingDuration.toMillis();
    System.out.printf("Entire request took %dms\n", millis);
  }
}).build();

Request Wrapping: wraps around the whole "outside" of an entire Request handling flow.

Request wrapping runs before Soklet resolves which ResourceMethod should handle the request. If you want to rewrite the HTTP method or path, return a modified Request via the consumer and Soklet will route using the wrapped request. You must call requestProcessor.accept(...) exactly once before returning; otherwise Soklet logs an error and returns a 500 response.

// Special scoped value so anyone can access the current Locale.
// For Java < 21, use ThreadLocal instead
public static final ScopedValue<Locale> CURRENT_LOCALE;

// Spin up the ScopedValue (or ThreadLocal)
static {
  CURRENT_LOCALE = ScopedValue.newInstance();
}

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).requestInterceptor(new RequestInterceptor() {
  @Override
  public void wrapRequest(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @NonNull Consumer<Request> requestProcessor
  ) {
    // Make the locale accessible by other code during this request...
    Locale locale = request.getLocales().get(0);

    // ...by binding it to a ScopedValue (or ThreadLocal).
    ScopedValue.where(CURRENT_LOCALE, locale).run(() -> {
      // You must call this so downstream processing can proceed
      requestProcessor.accept(request);
    });
  }
}).build();

// Then, elsewhere in your code while a request is being processed:

class ExampleService {
  void accessCurrentLocale() {
    // You now have access to the Locale bound to the logical scope
    // (or Thread) without having to pass it down the call stack
    Locale locale = CURRENT_LOCALE.orElse(Locale.getDefault());
  }
}

Request Intercepting (via RequestInterceptor): provides programmatic control over two processing steps.

  1. Invoking the appropriate ResourceMethod to acquire a MarshaledResponse
  2. Sending the MarshaledResponse over the wire to the client

You must call responseWriter.accept(...) exactly once before returning; otherwise Soklet logs an error and returns a 500 response.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).requestInterceptor(new RequestInterceptor() {
  @Override
  public void interceptRequest(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull Function<Request, MarshaledResponse> responseGenerator,
    @NonNull Consumer<MarshaledResponse> responseWriter
  ) {
    // Here's where you might start a DB transaction.
    // (MyDatabase is a hypothetical construct)
    MyDatabase.INSTANCE.beginTransaction();

    // Step 1: Invoke the Resource Method and acquire its response
    MarshaledResponse response = responseGenerator.apply(request);

    // Commit the DB transaction before sending the response
    // to reduce contention by keeping "open" time short
    MyDatabase.INSTANCE.commitTransaction();

    // Set a special header on the response via mutable copy
    response = response.copy().headers((mutableHeaders) -> {
      mutableHeaders.put("X-Powered-By", Set.of("Soklet"));
    }).finish();

    // Step 2: Send the finalized response over the wire
    responseWriter.accept(response);
  }
}).build();

Response Writing: monitor the response writing process for each MarshaledResponse - sending bytes over the wire - which may terminate exceptionally (e.g. unexpected client disconnect).

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willStartResponseWriting(
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull MarshaledResponse marshaledResponse
  ) {
    // Access to marshaledResponse here lets us see exactly
    // what will be going over the wire
    byte[] body = marshaledResponse.getBody().orElse(new byte[] {});
    System.out.printf("About to start writing response with " +
      "a %d-byte body...\n", body.length);
  }

  @Override
  public void didFinishResponseWriting(
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull MarshaledResponse marshaledResponse,
    @NonNull Duration responseWriteDuration,
    @Nullable Throwable throwable
  ) {
    long millis = responseWriteDuration.toMillis();
    System.out.printf("Took %dms to write response\n", millis);

    // You have access to the throwable that might have occurred
    // while writing the response.  This is useful to, for example,
    // determine trends in unexpected client disconnect rates
    if(throwable != null) {
      System.err.println("Exception occurred while writing response");
      throwable.printStackTrace();
    }
  }
}).build();

CORS Support

CORS is handled by CorsAuthorizer using
Cors metadata and returns
CorsPreflightResponse /
CorsResponse as needed.

Authorize All Origins:

SokletConfig config = SokletConfig.withHttpServer(server)
  // "Wildcard" (*) CORS authorization. Don't use this in production!
  .corsAuthorizer(CorsAuthorizer.acceptAllInstance())
  .build();

Authorize Whitelisted Origins:

Set<String> allowedOrigins = Set.of("https://www.revetware.com");

SokletConfig config = SokletConfig.withHttpServer(server)
  .corsAuthorizer(WhitelistedOriginsCorsAuthorizer.fromOrigins(allowedOrigins))
  .build();

...or be dynamic:

SokletConfig config = SokletConfig.withHttpServer(server)
  .corsAuthorizer(WhitelistedOriginsCorsAuthorizer.fromAuthorizer(
    (origin) -> origin.equals("https://www.revetware.com")
  ))
  .build();

Custom CORS logic:

SokletConfig config = SokletConfig.withHttpServer(server)
  .corsAuthorizer(new CorsAuthorizer() {
    // Any subdomain under soklet.com is permitted
    boolean originMatchesValidSubdomain(@NonNull Cors cors) {
      return cors.getOrigin().matches("https://(.+)\\.soklet\\.com");
    }

    @NonNull
    @Override
    public Optional<CorsPreflightResponse> authorizePreflight(
      @NonNull Request request,
      @NonNull Map<HttpMethod, ResourceMethod> availableResourceMethodsByHttpMethod
    ) {
      // Requests here are guaranteed to have the Cors value set
      Cors cors = request.getCors().orElseThrow();

      // Only greenlight our soklet.com subdomains
      if (originMatchesValidSubdomain(cors))
        return Optional.of(
          CorsPreflightResponse.withAccessControlAllowOrigin(cors.getOrigin())
            .accessControlAllowMethods(availableResourceMethodsByHttpMethod.keySet())
            .accessControlAllowHeaders(Set.of("*"))
            .accessControlAllowCredentials(true)
            .accessControlMaxAge(Duration.ofMinutes(10))
            .build()
        );

      return Optional.empty();
    }

    @NonNull
    @Override
    public Optional<CorsResponse> authorize(@NonNull Request request) {
      // Requests here are guaranteed to have the Cors value set
      Cors cors = request.getCors().orElseThrow();

      // Only greenlight our soklet.com subdomains
      if (originMatchesValidSubdomain(cors))
        return Optional.of(
          CorsResponse.withAccessControlAllowOrigin(cors.getOrigin())
            .accessControlExposeHeaders(Set.of("*"))
            .build()
        );

      return Optional.empty();
    }
  })
  .build();

Unit Testing

First, define something to test:

public class ReverseResource {
  // Reverse the input
  @POST("/reverse")
  public List<Integer> reverse(@RequestBody List<Integer> numbers) {
    return numbers.reversed();
  }

  // Reverse the input and set custom headers/cookies
  @POST("/reverse-again")
  public Response reverseAgain(@RequestBody List<Integer> numbers) {
    Integer largest = Collections.max(numbers);
    Instant lastRequest = Instant.now();

    return Response.withStatusCode(200)
      .headers(Map.of("X-Largest", Set.of(String.valueOf(largest))))
      .cookies(Set.of(
        ResponseCookie.with("lastRequest", lastRequest.toString()).build()
      ))
      .body(numbers.reversed())
      .build();
  }
}

Perform tests:

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

@Test
public void reverseUnitTest() {
  // Your Resource is a Plain Old Java Object, no Soklet dependency
  ReverseResource resource = new ReverseResource();

  List<Integer> input = List.of(1, 2, 3);
  List<Integer> expected = List.of(3, 2, 1);
  List<Integer> actual = resource.reverse(input);

  Assert.assertEquals("Reverse failed", expected, actual);
}

@Test
public void reverseAgainUnitTest() {
  ReverseResource resource = new ReverseResource();
  List<Integer> input = List.of(1, 2, 3);

  // Set expectations
  List<Integer> expectedBody = List.of(3, 2, 1);
  Integer expectedCode = 200;
  Integer expectedLargest = Collections.max(input);
  Instant lastRequestAfter = Instant.now();

  Response response = resource.reverseAgain(input);

  // Extract actuals
  Integer actualCode = response.getStatusCode();
  List<Integer> actualBody = (List<Integer>) response.getBody().orElseThrow();

  Integer actualLargest = response.getHeaders().get("X-Largest").stream()
    .findAny()
    .map(value -> Integer.valueOf(value))
    .orElseThrow();

  Instant actualLastRequest = response.getCookies().stream()
    .filter(responseCookie -> responseCookie.getName().equals("lastRequest"))
    .findAny()
    .map(responseCookie -> Instant.parse(responseCookie.getValue().orElseThrow()))
    .orElseThrow();

  // Verify expectations vs. actuals
  Assert.assertEquals("Bad status code", expectedCode, actualCode);
  Assert.assertEquals("Reverse failed", expectedBody, actualBody);
  Assert.assertEquals("Largest header failed", expectedLargest, actualLargest);
  Assert.assertTrue("Last request too early", actualLastRequest.isAfter(lastRequestAfter));
}

Integration Testing

First, define something to test:

public class HelloResource {
  // Hypothetical service that performs business logic
  private HelloService helloService;

  public HelloResource(HelloService helloService) {
    this.helloService = helloService;
  }

  // Respond with a 'hello' message, e.g. Hello, Mark
  @GET("/hello")
  public String hello(@QueryParameter String name) {
    return this.helloService.sayHelloTo(name);
  }
}

Perform tests:

Soklet's Simulator is available via Soklet to exercise full request/response flows without binding a port.

@Test
public void basicIntegrationTest() {
  // Just use your app's existing configuration
  SokletConfig config = obtainMySokletConfig();

  // Instead of running in a real HTTP server that listens on a port,
  // a simulator is provided against which you can issue requests
  // and receive responses.
  Soklet.runSimulator(config, (simulator -> {
    // Construct a request
    Request request = Request.withPath(HttpMethod.GET, "/hello")
      .queryParameters(Map.of("name", Set.of("Mark")))
      .build();

    // Perform the request and get a handle to the response
    HttpRequestResult httpRequestResult = simulator.performHttpRequest(request);
    MarshaledResponse marshaledResponse = httpRequestResult.getMarshaledResponse();

    // Verify status code
    Integer expectedCode = 200;
    Integer actualCode = marshaledResponse.getStatusCode();
    Assert.assertEquals("Bad status code", expectedCode, actualCode);

    // Verify response body
    marshaledResponse.getBody().ifPresentOrElse(body -> {
      String expectedBody = "Hello, Mark";
      String actualBody = new String(body, StandardCharsets.UTF_8);
      Assert.assertEquals("Bad response body", expectedBody, actualBody);
    }, () -> {
      Assert.fail("No response body");
    });
  }));
}

Metrics Collection

Soklet includes a MetricsCollector hook for collecting HTTP and SSE telemetry. The default in-memory
collector is enabled automatically, but you can replace or disable it:

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).metricsCollector(
  MetricsCollector.defaultInstance()
  // or MetricsCollector.disabledInstance()
).build();

Use MetricsCollector.SnapshotTextOptions and
MetricsCollector.MetricsFormat to control text output.

You can expose a /metrics endpoint by injecting MetricsCollector
into a ResourceMethod:

@GET("/metrics")
public MarshaledResponse getMetrics(@NonNull MetricsCollector metricsCollector) {
  SnapshotTextOptions options = SnapshotTextOptions
    .withMetricsFormat(MetricsFormat.PROMETHEUS)
    .histogramFormat(HistogramFormat.FULL_BUCKETS)
    .includeZeroBuckets(false)
    .build();

  String body = metricsCollector.snapshotText(options).orElse(null);

  if (body == null)
    return MarshaledResponse.fromStatusCode(204);

  return MarshaledResponse.withStatusCode(200)
    .headers(Map.of("Content-Type", Set.of("text/plain; charset=UTF-8")))
    .body(body.getBytes(StandardCharsets.UTF_8))
    .build();
}

Servlet Integration

Optional support is available for both legacy javax.servlet and current jakarta.servlet specifications. Just add the appropriate JAR to your project and you're good to go.

The Soklet website has in-depth Servlet integration documentation.

Learning More

Please refer to the official Soklet website https://www.soklet.com for detailed documentation.

Credits

Soklet stands on the shoulders of giants. Internally, it embeds code from the following OSS projects:

Yorumlar (0)

Sonuc bulunamadi