Soklet Logo

Core Concepts

Request Lifecycle

Soklet's LifecycleInterceptor provides a set of well-defined hooks into request processing.

It's useful for tasks like:

  • Logging requests and responses
  • Performing authentication and authorization
  • Modifying requests/responses before downstream processing occurs
  • Wrapping downstream code in a database transaction
  • Monitoring unexpected errors that occur during internal processing (see Event Logging)

This is similar to the Jakarta EE Servlet Filter concept, but provides additional functionality beyond "wrap the whole request".


Server Start/Stop

Execute code immediately before and after server startup and shutdown.

SokletConfig config = SokletConfig.withServer(
  Server.withPort(8080).build()
).lifecycleInterceptor(new LifecycleInterceptor() {
  @Override
  public void willStartServer(@Nonnull Server server) {
    // Perform startup tasks required prior to server launch
    MyPayrollSystem.INSTANCE.startLengthyWarmupProcess();
  }

  @Override
  public void didStartServer(@Nonnull Server server) {
    // Server has fully started up and is listening
    System.out.println("Server started.");
  }

  @Override
  public void willStopServer(@Nonnull Server server) {
    // Perform shutdown tasks required prior to server teardown
    MyPayrollSystem.INSTANCE.destroy();    
  }

  @Override
  public void didStopServer(@Nonnull Server server) {
    // Server has fully shut down
    System.out.println("Server stopped.");
  }
}).build();

References:

Request Handling

These methods are fired at the very start of request processing and the very end, respectively.

SokletConfig config = SokletConfig.withServer(
  Server.withPort(8080).build()
).lifecycleInterceptor(new LifecycleInterceptor() {
  @Override
  public void didStartRequestHandling(
    @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 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.toNanos() / 1_000_000.0;
    System.out.printf("Entire request took %dms\n", millis);
  }
}).build();

References:

Request Wrapping

Wraps around the whole "outside" of an entire request-handling flow. The "inside" of the flow is everything from Resource Method execution to writing response bytes to the client. For a more fine-grained approach, see Request Intercepting.

Wrapping a request is useful when you'd like to store off request-scoped information that needs to be made easily accessible to other parts of the system. Examples are:

  • Signed-in account
  • Locale
  • Time zone

Another use case is authorization - for example, suppose you'd like to restrict access to certain Resource Methods to signed-in accounts, or only those with a certain role. The Toy Store App shows how this can be accomplished.

To implement, a useful scoping mechanism is Java's ScopedValue<T> (Java 25+) or ThreadLocal<T>. The former is demonstrated below.

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

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

SokletConfig config = SokletConfig.withServer(
  Server.withPort(8080).build()
).lifecycleInterceptor(new LifecycleInterceptor() {
  @Override
  public void wrapRequest(
    @Nonnull Request request,
    @Nullable ResourceMethod resourceMethod,
    @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());
  }
}

References:

Request Intercepting

Conceptually, when a request comes in, Soklet will:

  1. Invoke the appropriate Resource Method to acquire a response
  2. Send the response over the wire to the client

Request Intercepting provides programmatic control over those two steps.

Example use cases are:

  • Wrapping Resource Methods with a database transaction (e.g. to ensure atomicity)
  • Customizing responses prior to sending over the wire

For a more coarse-grained approach, see Request Wrapping.

SokletConfig config = SokletConfig.withServer(
  Server.withPort(8080).build()
).lifecycleInterceptor(new LifecycleInterceptor() {
  @Override
  public void interceptRequest(
    @Nonnull Request request,
    @Nullable ResourceMethod resourceMethod,
    @Nonnull Function<Request, MarshaledResponse> responseProducer,
    @Nonnull Consumer<MarshaledResponse> responseWriter
  ) {
    // Here's where you might start a DB transaction
    MyDatabase.INSTANCE.beginTransaction();

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

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

    // You might also perform systemwide response adjustments here.
    // For example, set a special header 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();

References:

Response Writing

Monitor the response writing process - sending bytes over the wire - which may terminate exceptionally (e.g. unexpected client disconnect).

SokletConfig config = SokletConfig.withServer(
  Server.withPort(8080).build()
).lifecycleInterceptor(new LifecycleInterceptor() {
  @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 = processingDuration.toNanos() / 1_000_000.0;
    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();

References:

Event Logging

Soklet provides insight into unexpected errors that occur during internal processing that are not otherwise surfaced via LogEvent objects provided to LifecycleInterceptor::didReceiveLogEvent.

For example, you might want to specially monitor scenarios in which your ResponseMarshaler::forThrowable failed (that is, you attempted to write an error response for an exception that bubbled out, but that attempt threw an exception, forcing Soklet to write its own failsafe response) - you could do this by observing events with type LogEventType.RESPONSE_MARSHALER_FOR_THROWABLE_FAILED.

Your LifecycleInterceptor can listen for LogEvents like this:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

SokletConfig config = SokletConfig.withServer(
  Server.withPort(8080).build()
).lifecycleInterceptor(new LifecycleInterceptor() {
  // This example uses SLF4J. See https://www.slf4j.org
  private final Logger logger = 
    LoggerFactory.getLogger("com.soklet.example.LifecycleInterceptor");

  @Override
  public void didReceiveLogEvent(@Nonnull LogEvent logEvent) {
    // These properties are available in LogEvent
    LogEventType logEventType = logEvent.getLogEventType();
    String message = logEvent.getMessage();
    Optional<Throwable> throwable = logEvent.getThrowable();    
    Optional<Request> request = logEvent.getRequest();
    Optional<ResourceMethod> resourceMethod = logEvent.getResourceMethod();
    Optional<MarshaledResponse> marshaledResponse = logEvent.getMarshaledResponse();

    // Log the message however you like
    logger.warn(message, throwable.orElse(null));
  }
}).build();

References:

Previous
Response Writing