Code Samples
Toy Store App
This app showcases how you might build a robust production system with Soklet.
It lives in its own GitHub repository.
Feature highlights include:
- Authentication and role-based authorization
- Basic CRUD operations
- Dependency injection via Google Guice
- Relational database integration via Pyranid
- Context-awareness via ScopedValue (JEP 481)
- Internationalization via the JDK and Lokalized
- JSON requests/responses via Gson
- Logging via SLF4J / Logback
- Automated unit and integration tests via JUnit
- Ability to run in Docker
If you'd like fewer moving parts, a single-file "barebones" example is available.
Build and Run
First, clone the Git repository and set your working directory.
% git clone git@github.com:soklet/toystore-app.git
% cd toystore-app
% git clone git@github.com:soklet/toystore-app.git
% cd toystore-app
With Docker
This is the easiest way to run the Toy Store App. You don't need anything on your machine other than Docker. The app will run in its own sandboxed Java 23 Docker Container.
The Dockerfile is viewable here if you are curious about how it works.
You likely will want to have your app run inside of a Docker Container using this approach in a real deployment environment.
Build
% docker build . --file docker/Dockerfile --tag soklet/toystore
% docker build . --file docker/Dockerfile --tag soklet/toystore
Run
# Press Ctrl+C to stop the interactive container session
% docker run -it -p "8080:8080" -e APP_ENVIRONMENT="local" soklet/toystore
# Press Ctrl+C to stop the interactive container session
% docker run -it -p "8080:8080" -e APP_ENVIRONMENT="local" soklet/toystore
Test
% 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!
% 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!
Without Docker
The Toy Store App requires Apache Maven (you can skip Maven if you prefer to run directly through your IDE) and JDK 21+. If you need a JDK, Amazon Corretto is a free-to-use-commercially, production-ready distribution of OpenJDK that includes long-term support.
Build
% mvn compile
% mvn compile
Run
% APP_ENVIRONMENT="local" MAVEN_OPTS="--add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED" mvn -e exec:java -Dexec.mainClass="com.soklet.example.App"
% APP_ENVIRONMENT="local" MAVEN_OPTS="--add-opens java.base/java.time=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.lang=ALL-UNNAMED" mvn -e exec:java -Dexec.mainClass="com.soklet.example.App"
API Demonstration
Here we demonstrate how a client might interact with the Toy Store App. Implementation details are available in the API Modeling section of this document.
Authenticate
Given an email address and password, return account information and an authentication token (here, a JWT).
We specify headers with preferred locale and time zone so the server knows how to provide "friendly" localized descriptions in the response.
% curl -i -X POST 'http://localhost:8080/accounts/authenticate' \
-d '{"emailAddress": "admin@soklet.com", "password": "test123"}' \
-H "X-Locale: en-US" \
-H "X-Time-Zone: America/New_York"
HTTP/1.1 200 OK
Content-Length: 640
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"authenticationToken": "eyJhbG...c76fxc",
"account": {
"accountId": "08d0ba3e-b19c-4317-a146-583860fcb5fd",
"roleId": "ADMINISTRATOR",
"name": "Example Administrator",
"emailAddress": "admin@soklet.com",
"timeZone": "America/New_York",
"timeZoneDescription": "Eastern Time",
"locale": "en-US",
"localeDescription": "English (United States)",
"createdAt": "2024-06-09T13:25:27.038870Z",
"createdAtDescription": "Jun 9, 2024, 9:25 AM"
}
}
% curl -i -X POST 'http://localhost:8080/accounts/authenticate' \
-d '{"emailAddress": "admin@soklet.com", "password": "test123"}' \
-H "X-Locale: en-US" \
-H "X-Time-Zone: America/New_York"
HTTP/1.1 200 OK
Content-Length: 640
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"authenticationToken": "eyJhbG...c76fxc",
"account": {
"accountId": "08d0ba3e-b19c-4317-a146-583860fcb5fd",
"roleId": "ADMINISTRATOR",
"name": "Example Administrator",
"emailAddress": "admin@soklet.com",
"timeZone": "America/New_York",
"timeZoneDescription": "Eastern Time",
"locale": "en-US",
"localeDescription": "English (United States)",
"createdAt": "2024-06-09T13:25:27.038870Z",
"createdAtDescription": "Jun 9, 2024, 9:25 AM"
}
}
Create Toy
Now that we have an authentication token, add a toy to our database.
Because the server knows which account is making the request, the data in the response is formatted according to the account's preferred locale and timezone (here, en-US and America/New_York).
# Note: price is a string instead of a JSON number (float)
# to support exact arbitrary-precision decimals
% curl -i -X POST 'http://localhost:8080/toys' \
-d '{"name": "Test", "price": "1234.5", "currency": "GBP"}' \
-H "X-Authentication-Token: eyJhbG...c76fxc"
HTTP/1.1 200 OK
Content-Length: 351
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"toy": {
"toyId": "9bd5ea4d-ebd1-47f7-a8b4-0531b8655e5d",
"name": "Test",
"price": 1234.50,
"priceDescription": "£1,234.50",
"currencyCode": "GBP",
"currencySymbol": "£",
"currencyDescription": "British Pound",
"createdAt": "2024-06-09T13:44:26.388364Z",
"createdAtDescription": "Jun 9, 2024, 9:44 AM"
}
}
# Note: price is a string instead of a JSON number (float)
# to support exact arbitrary-precision decimals
% curl -i -X POST 'http://localhost:8080/toys' \
-d '{"name": "Test", "price": "1234.5", "currency": "GBP"}' \
-H "X-Authentication-Token: eyJhbG...c76fxc"
HTTP/1.1 200 OK
Content-Length: 351
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"toy": {
"toyId": "9bd5ea4d-ebd1-47f7-a8b4-0531b8655e5d",
"name": "Test",
"price": 1234.50,
"priceDescription": "£1,234.50",
"currencyCode": "GBP",
"currencySymbol": "£",
"currencyDescription": "British Pound",
"createdAt": "2024-06-09T13:44:26.388364Z",
"createdAtDescription": "Jun 9, 2024, 9:44 AM"
}
}
Purchase Toy
Let's purchase the toy that was just added.
% curl -i -X POST 'http://localhost:8080/toys/9bd5ea4d-ebd1-47f7-a8b4-0531b8655e5d/purchase' \
-d '{"creditCardNumber": "4111111111111111", "creditCardExpiration": "2028-03"}' \
-H "X-Authentication-Token: eyJhbG...c76fxc"
HTTP/1.1 200 OK
Content-Length: 523
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"purchase": {
"purchaseId": "9bd5ea4d-ebd1-47f7-a8b4-0531b8655e5d",
"accountId": "08d0ba3e-b19c-4317-a146-583860fcb5fd",
"toyId": "9bd5ea4d-ebd1-47f7-a8b4-0531b8655e5d",
"price": 1234.50,
"priceDescription": "£1,234.50",
"currencyCode": "GBP",
"currencySymbol": "£",
"currencyDescription": "British Pound",
"creditCardTransactionId": "72534075-d572-49fd-ae48-6c9644136e70",
"createdAt": "2024-06-09T14:12:08.100101Z",
"createdAtDescription": "Jun 9, 2024, 10:12 AM"
}
}
% curl -i -X POST 'http://localhost:8080/toys/9bd5ea4d-ebd1-47f7-a8b4-0531b8655e5d/purchase' \
-d '{"creditCardNumber": "4111111111111111", "creditCardExpiration": "2028-03"}' \
-H "X-Authentication-Token: eyJhbG...c76fxc"
HTTP/1.1 200 OK
Content-Length: 523
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"purchase": {
"purchaseId": "9bd5ea4d-ebd1-47f7-a8b4-0531b8655e5d",
"accountId": "08d0ba3e-b19c-4317-a146-583860fcb5fd",
"toyId": "9bd5ea4d-ebd1-47f7-a8b4-0531b8655e5d",
"price": 1234.50,
"priceDescription": "£1,234.50",
"currencyCode": "GBP",
"currencySymbol": "£",
"currencyDescription": "British Pound",
"creditCardTransactionId": "72534075-d572-49fd-ae48-6c9644136e70",
"createdAt": "2024-06-09T14:12:08.100101Z",
"createdAtDescription": "Jun 9, 2024, 10:12 AM"
}
}
Internationalization (i18n)
Here we specify X-Locale and X-Time-Zone headers to format our response in a different locale and time zone - in this case, pt-BR (Brazilian Portuguese) and America/Sao_Paulo (São Paulo time, UTC-03:00).
% curl -i -X POST 'http://localhost:8080/toys' \
-d '{"name": "Bola de futebol", "price": "50", "currency": "BRL"}' \
-H "X-Authentication-Token: eyJhbG...c76fxc" \
-H "X-Locale: pt-BR" \
-H "X-Time-Zone: America/Sao_Paulo"
HTTP/1.1 200 OK
Content-Length: 362
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"toy": {
"toyId": "3c7c179a-a824-4026-b00c-811710192ff2",
"name": "Bola de futebol",
"price": 50.00,
"priceDescription": "R$ 50,00",
"currencyCode": "BRL",
"currencySymbol": "R$",
"currencyDescription": "Real brasileiro",
"createdAt": "2024-06-09T14:03:49.748571Z",
"createdAtDescription": "9 de jun. de 2024 11:03"
}
}
% curl -i -X POST 'http://localhost:8080/toys' \
-d '{"name": "Bola de futebol", "price": "50", "currency": "BRL"}' \
-H "X-Authentication-Token: eyJhbG...c76fxc" \
-H "X-Locale: pt-BR" \
-H "X-Time-Zone: America/Sao_Paulo"
HTTP/1.1 200 OK
Content-Length: 362
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"toy": {
"toyId": "3c7c179a-a824-4026-b00c-811710192ff2",
"name": "Bola de futebol",
"price": 50.00,
"priceDescription": "R$ 50,00",
"currencyCode": "BRL",
"currencySymbol": "R$",
"currencyDescription": "Real brasileiro",
"createdAt": "2024-06-09T14:03:49.748571Z",
"createdAtDescription": "9 de jun. de 2024 11:03"
}
}
Error messages are localized as well. Here we supply a negative price and forget to specify a currency.
% curl -i -X POST 'http://localhost:8080/toys' \
-d '{"name": "Bola de futebol", "price": "-50"}' \
-H "X-Authentication-Token: eyJhbG...c76fxc" \
-H "X-Locale: pt-BR" \
-H "X-Time-Zone: America/Sao_Paulo"
HTTP/1.1 422 Unprocessable Content
Content-Length: 233
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"summary": "O preço não pode ser negativo. A moeda é obrigatória.",
"generalErrors": [],
"fieldErrors": {
"price": "O preço não pode ser negativo.",
"currency": "A moeda é obrigatória."
},
"metadata": {}
}
% curl -i -X POST 'http://localhost:8080/toys' \
-d '{"name": "Bola de futebol", "price": "-50"}' \
-H "X-Authentication-Token: eyJhbG...c76fxc" \
-H "X-Locale: pt-BR" \
-H "X-Time-Zone: America/Sao_Paulo"
HTTP/1.1 422 Unprocessable Content
Content-Length: 233
Content-Type: application/json;charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
{
"summary": "O preço não pode ser negativo. A moeda é obrigatória.",
"generalErrors": [],
"fieldErrors": {
"price": "O preço não pode ser negativo.",
"currency": "A moeda é obrigatória."
},
"metadata": {}
}
Context-Awareness
Your code should always know its context with respect to the current thread of execution.
Examples of contextual information include:
- Where am I running? (e.g. web request, background thread)
- Who is the authenticated user, if any?
- How am I internationalized? (e.g. time zone, language, country)
A good implementation should be threadsafe and not require a "context" object to be passed all the way down the call stack. Java supports this via ScopedValue<T> and ThreadLocal<T>.
The Toy Store App defines its own CurrentContext type to provide a uniform interface for working with contextual data.
We can wrap processing for each request with this context...
SokletConfig config = SokletConfig.withServer(...)
.lifecycleInterceptor(new LifecycleInterceptor() {
@Override
public void wrapRequest(
@Nonnull Request request,
@Nullable ResourceMethod resourceMethod,
@Nonnull Consumer<Request> requestProcessor
) {
// Ensure a "current context" exists for all request-handling code
CurrentContext.withRequest(request).build().run(() -> {
requestProcessor.accept(request);
});
})
.build();
SokletConfig config = SokletConfig.withServer(...)
.lifecycleInterceptor(new LifecycleInterceptor() {
@Override
public void wrapRequest(
@Nonnull Request request,
@Nullable ResourceMethod resourceMethod,
@Nonnull Consumer<Request> requestProcessor
) {
// Ensure a "current context" exists for all request-handling code
CurrentContext.withRequest(request).build().run(() -> {
requestProcessor.accept(request);
});
})
.build();
...and it becomes accessible to all of our code downstream.
Let's look at an example of downstream code accessing the current context. This snippet is simplified - the real Toy Store App prefers Google Guice's Provider<T> to a static CurrentContext::get invocation and the text localization is handled more elegantly - but the concept is the same.
// Some business logic downstream from request handling
public class ToyService {
@Nonnull
public UUID createToy(@Nonnull ToyCreateRequest request) {
if(request.name == null) {
// Create a localized error message.
//
// CurrentContext might have had its locale set via:
// - Request headers
// - Authenticated account
// - Set explicitly on the context
//
// ...but the code here does not know or care how that was done.
// All it knows is that there is a locale available
CurrentContext currentContext = CurrentContext.get();
Locale locale = currentContext.getLocale();
String localizedMessage = ExampleStrings.get(
"toy-name-required-message", locale
);
throw new ExampleException(localizedMessage);
}
// Other code elided
}
}
// Some business logic downstream from request handling
public class ToyService {
@Nonnull
public UUID createToy(@Nonnull ToyCreateRequest request) {
if(request.name == null) {
// Create a localized error message.
//
// CurrentContext might have had its locale set via:
// - Request headers
// - Authenticated account
// - Set explicitly on the context
//
// ...but the code here does not know or care how that was done.
// All it knows is that there is a locale available
CurrentContext currentContext = CurrentContext.get();
Locale locale = currentContext.getLocale();
String localizedMessage = ExampleStrings.get(
"toy-name-required-message", locale
);
throw new ExampleException(localizedMessage);
}
// Other code elided
}
}
You usually want to associate the authenticated account with a context:
Account authenticatedAccount = doSomeWorkToAuthenticate(...);
CurrentContext.withAccount(authenticatedAccount)
.request(request)
.build().run(() -> {
// Downstream code goes here.
Optional<Account> account = CurrentContext.get().getAccount();
});
Account authenticatedAccount = doSomeWorkToAuthenticate(...);
CurrentContext.withAccount(authenticatedAccount)
.request(request)
.build().run(() -> {
// Downstream code goes here.
Optional<Account> account = CurrentContext.get().getAccount();
});
You might also create a context that's not tied to a request or account. This is useful for scenarios like background threads and integration tests.
CurrentContext.with(Locale.US, ZoneId.of("America/New_York"))
.build().run(() -> {
// Downstream code goes here.
ZoneId timeZone = CurrentContext.get().getTimeZone();
});
CurrentContext.with(Locale.US, ZoneId.of("America/New_York"))
.build().run(() -> {
// Downstream code goes here.
ZoneId timeZone = CurrentContext.get().getTimeZone();
});
It should also be possible to nest contexts and/or permit context mutation.
For example, suppose at the start of request handling you set a current context with the request bound to it. Later on, you might authenticate an account and want to associate it with the context.
The CurrentContext implementation for the Toy Store App solves this by maintaining an internal ScopedValue<T> that holds a Deque<CurrentContext>. This keeps the CurrentContext instances immutable and provides familiar stack-like semantics (push a context on the stack before execution, it gets popped after execution).
// First, a request comes in. Let's push a context on the stack
CurrentContext.withRequest(request).build().run(() -> {
// Later on downstream, we authenticate an account.
CurrentContext requestOnlyContext = CurrentContext.get();
Account authenticatedAccount = doSomeWorkToAuthenticate(...);
// Push another context (which includes the account) on the stack.
CurrentContext.withRequest(requestOnlyContext.getRequest().get())
.account(authenticatedAccount)
.build().run(() -> {
// Now CurrentContext.get() vends the new context
CurrentContext requestAccountContext = CurrentContext.get();
Optional<Account> account = requestAccountContext.getAccount();
});
// Above context has been popped off the stack, we are now back to
// our request-only context when we invoke CurrentContext.get()
});
// First, a request comes in. Let's push a context on the stack
CurrentContext.withRequest(request).build().run(() -> {
// Later on downstream, we authenticate an account.
CurrentContext requestOnlyContext = CurrentContext.get();
Account authenticatedAccount = doSomeWorkToAuthenticate(...);
// Push another context (which includes the account) on the stack.
CurrentContext.withRequest(requestOnlyContext.getRequest().get())
.account(authenticatedAccount)
.build().run(() -> {
// Now CurrentContext.get() vends the new context
CurrentContext requestAccountContext = CurrentContext.get();
Optional<Account> account = requestAccountContext.getAccount();
});
// Above context has been popped off the stack, we are now back to
// our request-only context when we invoke CurrentContext.get()
});
You can see an example of context nesting in the Toy Store App's AppModule.
References:
Dependency Injection
The Toy Store App uses Google Guice to perform Dependency Injection.
This has multiple benefits, including:
- We are relieved of the burden of constructing the object graph ourselves - our types declare their dependencies and it's Guice's job to provide and instantiate them. This eliminates considerable amounts of bookkeeping code
- We can easily override application components in a surgical way, e.g. running an integration test against an instance of the whole system, but where the credit card processor is replaced with a version that always declines charges (see Integration Tests below for an example)
- Guice's AssistedInject functionality lends itself well to constructing "API response" types at runtime (see API Modeling below for details)
Soklet can be configured to ask Guice for instances by specifying a custom InstanceProvider. Now, your Soklet-created instances are dependency-injected just like the rest of your application is.
// Standard Guice setup
Injector injector = Guice.createInjector(new AppModule());
SokletConfig config = SokletConfig.withServer(...)
.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();
// Standard Guice setup
Injector injector = Guice.createInjector(new AppModule());
SokletConfig config = SokletConfig.withServer(...)
.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();
To demonstrate Dependency Injection, let's look at the Toy Store's AccountService type. Marking its constructor with Guice's @Inject annotation lets Guice know it's OK to provide dependencies.
public class AccountService {
@Inject
public AccountService(
@Nonnull PasswordManager passwordManager,
@Nonnull Database database,
@Nonnull Strings strings
) {
this.passwordManager = passwordManager;
this.database = database;
this.strings = strings;
}
// Rest of type elided
}
public class AccountService {
@Inject
public AccountService(
@Nonnull PasswordManager passwordManager,
@Nonnull Database database,
@Nonnull Strings strings
) {
this.passwordManager = passwordManager;
this.database = database;
this.strings = strings;
}
// Rest of type elided
}
Similarly, our AccountService is injected into our AccountResource.
@Resource
public class AccountResource {
@Inject
public AccountResource(
@Nonnull AccountService accountService,
@Nonnull AccountResponseFactory accountResponseFactory
) {
this.accountService = accountService;
this.accountResponseFactory = accountResponseFactory;
}
// Rest of type elided
}
@Resource
public class AccountResource {
@Inject
public AccountResource(
@Nonnull AccountService accountService,
@Nonnull AccountResponseFactory accountResponseFactory
) {
this.accountService = accountService;
this.accountResponseFactory = accountResponseFactory;
}
// Rest of type elided
}
Suppose you did not use a tool to perform Dependency Injection. It would be your responsibility to figure out the order in which your objects should be instantiated in order to satisfy dependency graphs. Further, you would need to create mechanisms to "break" circular dependencies, support lazy instantiation, and so forth. Alternatively, you might warp your object model by using static references in an effort to avoid bookkeeping - this produces tightly-coupled code that is difficult to test.
Guice allows you to succinctly express intent - "for this type, these are the tools I need to do the job" - clarifying purpose and minimizing noise. Let the machine do the grunt work of dependency analysis for you!
You can see how dependency injection is configured in the Toy Store App's App and AppModule types.
References:
JSON Handling
The Toy Store App speaks JSON everywhere in a consistent fashion. This means:
- Request bodies are in JSON format
- Response bodies (including errors) are in JSON format
- Both requests and responses follow the same serialization rules (e.g. a
LocalDateshould always be of the ISO 8601 formyyyy-MM-dd)
Google Gson is used for JSON manipulation because of its high quality, strong featureset, lack of dependencies, and proven track record in production systems since 2008.
First, we provide a method to acquire a Gson instance, defining its formatting and serialization rules. The instance is threadsafe and used across the entire app.
@Nonnull
public Gson provideGson(@Nonnull Configuration configuration) {
GsonBuilder gsonBuilder = new GsonBuilder()
// Nicely format JSON output
.setPrettyPrinting()
// Don't perform any custom escaping
.disableHtmlEscaping()
// Support [de]serialization for java.util.Locale.
// Uses IETF BCP 47 language tag format, e.g. "pt-BR"
.registerTypeAdapter(Locale.class, new TypeAdapter<Locale>() {
@Override
public void write(@Nonnull JsonWriter jsonWriter,
@Nonnull Locale locale) throws IOException {
jsonWriter.value(locale.toLanguageTag());
}
@Override
public Locale read(@Nonnull JsonReader jsonReader) throws IOException {
return Locale.forLanguageTag(jsonReader.nextString());
}
});
// Not shown: custom serialization for other types
return gsonBuilder.create();
}
@Nonnull
public Gson provideGson(@Nonnull Configuration configuration) {
GsonBuilder gsonBuilder = new GsonBuilder()
// Nicely format JSON output
.setPrettyPrinting()
// Don't perform any custom escaping
.disableHtmlEscaping()
// Support [de]serialization for java.util.Locale.
// Uses IETF BCP 47 language tag format, e.g. "pt-BR"
.registerTypeAdapter(Locale.class, new TypeAdapter<Locale>() {
@Override
public void write(@Nonnull JsonWriter jsonWriter,
@Nonnull Locale locale) throws IOException {
jsonWriter.value(locale.toLanguageTag());
}
@Override
public Locale read(@Nonnull JsonReader jsonReader) throws IOException {
return Locale.forLanguageTag(jsonReader.nextString());
}
});
// Not shown: custom serialization for other types
return gsonBuilder.create();
}
Next, we use the Gson instance for request and response marshaling.
Request Body Marshaling
SokletConfig config = SokletConfig.withServer(...)
.requestBodyMarshaler(new RequestBodyMarshaler() {
@Nonnull
@Override
public Optional<Object> marshalRequestBody(
@Nonnull Request request,
@Nonnull ResourceMethod resourceMethod,
@Nonnull Parameter parameter,
@Nonnull Type requestBodyType
) {
String requestBodyAsString = request.getBodyAsString().orElse(null);
// Handle empty request body
if (requestBodyAsString == null)
return Optional.empty();
// Use Gson to turn the request body JSON into a Java type
return Optional.of(gson.fromJson(requestBodyAsString, requestBodyType));
}
}).build();
SokletConfig config = SokletConfig.withServer(...)
.requestBodyMarshaler(new RequestBodyMarshaler() {
@Nonnull
@Override
public Optional<Object> marshalRequestBody(
@Nonnull Request request,
@Nonnull ResourceMethod resourceMethod,
@Nonnull Parameter parameter,
@Nonnull Type requestBodyType
) {
String requestBodyAsString = request.getBodyAsString().orElse(null);
// Handle empty request body
if (requestBodyAsString == null)
return Optional.empty();
// Use Gson to turn the request body JSON into a Java type
return Optional.of(gson.fromJson(requestBodyAsString, requestBodyType));
}
}).build();
Response Marshaling
// This example only shows Happy Path marshaling for brevity
SokletConfig config = SokletConfig.withServer(...)
.responseMarshaler(new DefaultResponseMarshaler() {
@Nonnull
@Override
public MarshaledResponse forHappyPath(
@Nonnull Request request,
@Nonnull Response response,
@Nonnull ResourceMethod resourceMethod
) {
// Use Gson to turn response objects into JSON to go over the wire
Object bodyObject = response.getBody().orElse(null);
byte[] body = bodyObject == null ? null
: gson.toJson(bodyObject).getBytes(StandardCharsets.UTF_8);
// Ensure content type header is set
Map<String, Set<String>> headers = new HashMap<>(response.getHeaders());
headers.put("Content-Type", Set.of("application/json;charset=UTF-8"));
return MarshaledResponse.withStatusCode(response.getStatusCode())
.headers(headers)
.cookies(response.getCookies())
.body(body)
.build();
}
// Rest of ResponseMarshaler elided
}).build();
// This example only shows Happy Path marshaling for brevity
SokletConfig config = SokletConfig.withServer(...)
.responseMarshaler(new DefaultResponseMarshaler() {
@Nonnull
@Override
public MarshaledResponse forHappyPath(
@Nonnull Request request,
@Nonnull Response response,
@Nonnull ResourceMethod resourceMethod
) {
// Use Gson to turn response objects into JSON to go over the wire
Object bodyObject = response.getBody().orElse(null);
byte[] body = bodyObject == null ? null
: gson.toJson(bodyObject).getBytes(StandardCharsets.UTF_8);
// Ensure content type header is set
Map<String, Set<String>> headers = new HashMap<>(response.getHeaders());
headers.put("Content-Type", Set.of("application/json;charset=UTF-8"));
return MarshaledResponse.withStatusCode(response.getStatusCode())
.headers(headers)
.cookies(response.getCookies())
.body(body)
.build();
}
// Rest of ResponseMarshaler elided
}).build();
References:
Database Access
The Toy Store App uses Soklet's sister project Pyranid to perform operations against a relational database. Like Soklet, Pyranid has zero dependencies, making it easy to drop into any system. It is not an ORM in the traditional sense: to use Pyranid, you write plain SQL, and it handles marshaling between Java and SQL types, smoothing over the rough edges of JDBC.
The Toy Store App uses HQSQLDB as its DBMS because it lives entirely in-memory, making it useful for examples, experimentation, and integration testing. A real system would generally use a DBMS like PostgreSQL instead.
Setup
We create a shared Database (this type is threadsafe), which is backed by a javax.sql.Datasource.
Note that we configure the Database using the same dependency injection mechanism used by the rest of the Toy Store App - this means when Pyranid needs to instantiate ResultSet rows, it will use Google Guice to vend them.
@Nonnull
public Database provideDatabase(@Nonnull Injector injector) {
// Example in-memory datasource for HSQLDB
JDBCDataSource dataSource = new JDBCDataSource();
dataSource.setUrl("jdbc:hsqldb:mem:example");
dataSource.setUser("sa");
dataSource.setPassword("");
// Create a Pyranid handle to our database
return Database.forDataSource(dataSource)
// Use Google Guice when Pyranid needs to vend instances
.instanceProvider(new DefaultInstanceProvider() {
@Override
@Nonnull
public <T> T provide(
@Nonnull StatementContext<T> statementContext,
@Nonnull Class<T> instanceType
) {
return injector.getInstance(instanceType);
}
}).build();
}
@Nonnull
public Database provideDatabase(@Nonnull Injector injector) {
// Example in-memory datasource for HSQLDB
JDBCDataSource dataSource = new JDBCDataSource();
dataSource.setUrl("jdbc:hsqldb:mem:example");
dataSource.setUser("sa");
dataSource.setPassword("");
// Create a Pyranid handle to our database
return Database.forDataSource(dataSource)
// Use Google Guice when Pyranid needs to vend instances
.instanceProvider(new DefaultInstanceProvider() {
@Override
@Nonnull
public <T> T provide(
@Nonnull StatementContext<T> statementContext,
@Nonnull Class<T> instanceType
) {
return injector.getInstance(instanceType);
}
}).build();
}
The Pyranid documentation has details on how to further customize your Database instance.
Data Representation
We use Java records for 1:1 representations of DB tables/resultsets.
// Maps to the 'purchase' table and conforms to nullability rules
public record Purchase(
@Nonnull UUID purchaseId,
@Nonnull UUID accountId,
@Nonnull UUID toyId,
@Nonnull BigDecimal price,
@Nonnull Currency currency,
@Nonnull @DatabaseColumn("credit_card_txn_id") String creditCardTransactionId,
@Nonnull Instant createdAt
) {
public Purchase {
requireNonNull(purchaseId);
requireNonNull(accountId);
requireNonNull(toyId);
requireNonNull(price);
requireNonNull(currency);
requireNonNull(creditCardTransactionId);
requireNonNull(createdAt);
}
}
// Maps to the 'purchase' table and conforms to nullability rules
public record Purchase(
@Nonnull UUID purchaseId,
@Nonnull UUID accountId,
@Nonnull UUID toyId,
@Nonnull BigDecimal price,
@Nonnull Currency currency,
@Nonnull @DatabaseColumn("credit_card_txn_id") String creditCardTransactionId,
@Nonnull Instant createdAt
) {
public Purchase {
requireNonNull(purchaseId);
requireNonNull(accountId);
requireNonNull(toyId);
requireNonNull(price);
requireNonNull(currency);
requireNonNull(creditCardTransactionId);
requireNonNull(createdAt);
}
}
Toy Store App types that represent database rows and resultsets are kept in the model.db package.
Queries
Data is pulled using vanilla SQL queries. Pyranid handles injecting PreparedStatement parameters and maps ResultSet rows to Java types.
// Finds a toy by its unique identifier
@Nonnull
public Optional<Toy> findToyById(@Nullable UUID toyId) {
if (toyId == null)
return Optional.empty();
return getDatabase().queryForObject("""
SELECT *
FROM toy
WHERE toy_id=?
""", Toy.class, toyId);
}
// Finds a toy by its unique identifier
@Nonnull
public Optional<Toy> findToyById(@Nullable UUID toyId) {
if (toyId == null)
return Optional.empty();
return getDatabase().queryForObject("""
SELECT *
FROM toy
WHERE toy_id=?
""", Toy.class, toyId);
}
Transactions
A key feature of relational databases is the ability to perform atomic operations.
Because the Toy Store App is an example system, we can take the simple approach of wrapping every Resource Method in a transaction - if an exception bubbles out, the transaction is rolled back. If no exception bubbles out, the transaction is committed.
// Let's wrap our Resource Method invocations with transactions
SokletConfig config = SokletConfig.withServer(...)
.lifecycleInterceptor(new LifecycleInterceptor() {
@Override
public void interceptRequest(
@Nonnull Request request,
@Nullable ResourceMethod resourceMethod,
@Nonnull Function<Request, MarshaledResponse> responseGenerator,
@Nonnull Consumer<MarshaledResponse> responseWriter
) {
// (Other parts of this method elided)
// Invoke the Resource Method with the given request.
// Wrap the invocation in a database transaction.
// If an exception occurs, the transaction will be rolled back.
MarshaledResponse marshaledResponse = database.transaction(() ->
Optional.of(responseGenerator.apply(request))
).get();
// Write the response as normal (transaction has already committed)
responseWriter.accept(marshaledResponse);
}
// Rest of LifecycleInterceptor elided
}).build();
// Let's wrap our Resource Method invocations with transactions
SokletConfig config = SokletConfig.withServer(...)
.lifecycleInterceptor(new LifecycleInterceptor() {
@Override
public void interceptRequest(
@Nonnull Request request,
@Nullable ResourceMethod resourceMethod,
@Nonnull Function<Request, MarshaledResponse> responseGenerator,
@Nonnull Consumer<MarshaledResponse> responseWriter
) {
// (Other parts of this method elided)
// Invoke the Resource Method with the given request.
// Wrap the invocation in a database transaction.
// If an exception occurs, the transaction will be rolled back.
MarshaledResponse marshaledResponse = database.transaction(() ->
Optional.of(responseGenerator.apply(request))
).get();
// Write the response as normal (transaction has already committed)
responseWriter.accept(marshaledResponse);
}
// Rest of LifecycleInterceptor elided
}).build();
Performance Note
For systems with many concurrent users, you should be more surgical with your use of transactions in order to minimize contention. Consider performing short-lived, explicit transactions downstream where possible as opposed to the "top level" of a Resource Method invocation.
Now that we are executing within a transactional context, Pyranid automatically has all SQL statements participate in it without having to pass the transaction all the way down the call stack - this is similar to the Context Awareness concept we have built into the Toy Store App. This lets you "just code", knowing that transaction management is handled upstream so you don't have to worry about it.
Here's an example of how we might INSERT a Toy record. First, our Resource Method in ToyResource:
@Nonnull
@AuthorizationRequired({RoleId.EMPLOYEE, RoleId.ADMINISTRATOR})
@POST("/toys")
public ToyResponseHolder createToy(
@Nonnull @RequestBody ToyCreateRequest request
) {
UUID toyId = getToyService().createToy(request);
Toy toy = getToyService().findToyById(toyId).get();
return new ToyResponseHolder(getToyResponseFactory().create(toy));
}
@Nonnull
@AuthorizationRequired({RoleId.EMPLOYEE, RoleId.ADMINISTRATOR})
@POST("/toys")
public ToyResponseHolder createToy(
@Nonnull @RequestBody ToyCreateRequest request
) {
UUID toyId = getToyService().createToy(request);
Toy toy = getToyService().findToyById(toyId).get();
return new ToyResponseHolder(getToyResponseFactory().create(toy));
}
Then, our ToyService where the INSERT occurs:
@Nonnull
public UUID createToy(@Nonnull ToyCreateRequest request) {
UUID toyId = UUID.randomUUID();
// (Validation elided)
try {
// This automatically participates in the current transaction
// (if one exists)
getDatabase().execute("""
INSERT INTO toy (
toy_id,
name,
price,
currency
) VALUES (?,?,?,?)
""", toyId, name, price, currency);
} catch (DatabaseException e) {
// If this is a unique constraint violation on the 'name' field,
// handle it specially by exposing a helpful message to the caller.
// For HSQLDB, we need to examine the error message to find the constraint name
if (e.getMessage().contains("TOY_NAME_UNIQUE_IDX")) {
String nameError = getStrings().get("There is already a toy named '{{name}}'.", Map.of("name", name));
throw ApplicationException.withStatusCode(422)
.fieldErrors(Map.of("name", nameError))
.build();
} else {
// Some other problem; just bubble out
throw e;
}
}
return toyId;
}
@Nonnull
public UUID createToy(@Nonnull ToyCreateRequest request) {
UUID toyId = UUID.randomUUID();
// (Validation elided)
try {
// This automatically participates in the current transaction
// (if one exists)
getDatabase().execute("""
INSERT INTO toy (
toy_id,
name,
price,
currency
) VALUES (?,?,?,?)
""", toyId, name, price, currency);
} catch (DatabaseException e) {
// If this is a unique constraint violation on the 'name' field,
// handle it specially by exposing a helpful message to the caller.
// For HSQLDB, we need to examine the error message to find the constraint name
if (e.getMessage().contains("TOY_NAME_UNIQUE_IDX")) {
String nameError = getStrings().get("There is already a toy named '{{name}}'.", Map.of("name", name));
throw ApplicationException.withStatusCode(422)
.fieldErrors(Map.of("name", nameError))
.build();
} else {
// Some other problem; just bubble out
throw e;
}
}
return toyId;
}
A more detailed treatment of transaction handling, including concepts like isolation, savepoints, rollbacks, and having multiple threads participate in the same transaction, is available in the Pyranid documentation.
References:
API Modeling
The Toy Store App decouples its API request and response body types from their database representations. This allows system internals to grow and change over time, while presenting a consistent interface to clients. We also take advantage of Java's static typing to write effective automated tests which simulate requests and responses from a client's perspective.
Requests
Toy Store App types that encapsulate API request bodies are kept in the model.api.request package.
For example, here is a JSON request body we might accept for POST /toys:
{
"name": "Test",
"price": "12.34",
"currency": "USD"
}
{
"name": "Test",
"price": "12.34",
"currency": "USD"
}
We model this as a record type:
public record ToyCreateRequest(
@Nullable String name,
@Nullable BigDecimal price,
@Nullable Currency currency
) {}
public record ToyCreateRequest(
@Nullable String name,
@Nullable BigDecimal price,
@Nullable Currency currency
) {}
Depending on how you prefer to perform validation, you might use "weaker" field types like String instead of BigDecimal.
The Toy Store App attempts to strike a reasonable balance - correctly structured data is generally assumed (most UIs would enforce numeric-only input for price and present a dropdown or radio group for currency values), but nullability can be an "expected" point of failure (a user might reasonably forget to enter a required field like name and it is the responsibility of the backend to remind them).
In these Toy Store App scenarios, if an incorrect structure is provided (e.g. currency with a numeric value of 123 instead of a string "GBP"), HTTP 400 Bad Request is returned to the client. If the request body is structurally valid but another issue exists (e.g. name is required but not specified or price is a negative number), HTTP 422 Unprocessable Content is returned.
Using types like ToyCreateRequest for request body values, our Resource Methods are amenable to automated testing and easy to understand at-a-glance:
@AuthorizationRequired({RoleId.EMPLOYEE, RoleId.ADMINISTRATOR})
@POST("/toys")
public ToyResponseHolder createToy(@RequestBody ToyCreateRequest request) {
UUID toyId = getToyService().createToy(request);
Toy toy = getToyService().findToyById(toyId).get();
// See 'Responses' section below for details
// on how the Toy Store App structures its responses
return new ToyResponseHolder(getToyResponseFactory().create(toy));
}
@AuthorizationRequired({RoleId.EMPLOYEE, RoleId.ADMINISTRATOR})
@POST("/toys")
public ToyResponseHolder createToy(@RequestBody ToyCreateRequest request) {
UUID toyId = getToyService().createToy(request);
Toy toy = getToyService().findToyById(toyId).get();
// See 'Responses' section below for details
// on how the Toy Store App structures its responses
return new ToyResponseHolder(getToyResponseFactory().create(toy));
}
The behavior of @RequestBody is controlled by RequestBodyMarshaler. Here's how it looks in the Toy Store App:
// Other parts of configuration are elided for clarity
SokletConfig config = SokletConfig.withServer(
Server.withPort(8080).build()
).requestBodyMarshaler(new RequestBodyMarshaler() {
// Logger for request bodies
@Nonnull
private final Logger logger =
LoggerFactory.getLogger("com.soklet.example.RequestBodyMarshaler");
@Nonnull
@Override
public Optional<Object> marshalRequestBody(
@Nonnull Request request,
@Nonnull ResourceMethod resourceMethod,
@Nonnull Parameter parameter,
@Nonnull Type requestBodyType
) {
// If we're here, it means a @RequestBody annotation on a
// Resource Method was detected and we need to supply it with a value.
String requestBodyAsString = request.getBodyAsString().orElse(null);
// Ignore empty request bodies
if (requestBodyAsString == null)
return Optional.empty();
logger.debug("Request body:\n{}", requestBodyAsString);
// Use Gson to turn the request body JSON into a Java type
return Optional.of(gson.fromJson(requestBodyAsString, requestBodyType));
}
}).build();
// Other parts of configuration are elided for clarity
SokletConfig config = SokletConfig.withServer(
Server.withPort(8080).build()
).requestBodyMarshaler(new RequestBodyMarshaler() {
// Logger for request bodies
@Nonnull
private final Logger logger =
LoggerFactory.getLogger("com.soklet.example.RequestBodyMarshaler");
@Nonnull
@Override
public Optional<Object> marshalRequestBody(
@Nonnull Request request,
@Nonnull ResourceMethod resourceMethod,
@Nonnull Parameter parameter,
@Nonnull Type requestBodyType
) {
// If we're here, it means a @RequestBody annotation on a
// Resource Method was detected and we need to supply it with a value.
String requestBodyAsString = request.getBodyAsString().orElse(null);
// Ignore empty request bodies
if (requestBodyAsString == null)
return Optional.empty();
logger.debug("Request body:\n{}", requestBodyAsString);
// Use Gson to turn the request body JSON into a Java type
return Optional.of(gson.fromJson(requestBodyAsString, requestBodyType));
}
}).build();
More details on this process are available in the Request Body documentation.
Responses
Toy Store App types that encapsulate API response bodies are kept in the model.api.response package.
We never directly expose our internal data model through the API.
For example, model.db.Account represents a row in our database's account table. This is internal to the Toy Store App and never publicly exposed:
public record Account(
@Nonnull UUID accountId,
@Nonnull RoleId roleId,
@Nonnull String name,
@Nonnull String emailAddress,
@Nonnull String password,
@Nonnull ZoneId timeZone,
@Nonnull Locale locale,
@Nonnull Instant createdAt
) {
public Account {
requireNonNull(accountId);
requireNonNull(roleId);
requireNonNull(name);
requireNonNull(emailAddress);
requireNonNull(password);
requireNonNull(timeZone);
requireNonNull(locale);
requireNonNull(createdAt);
}
}
public record Account(
@Nonnull UUID accountId,
@Nonnull RoleId roleId,
@Nonnull String name,
@Nonnull String emailAddress,
@Nonnull String password,
@Nonnull ZoneId timeZone,
@Nonnull Locale locale,
@Nonnull Instant createdAt
) {
public Account {
requireNonNull(accountId);
requireNonNull(roleId);
requireNonNull(name);
requireNonNull(emailAddress);
requireNonNull(password);
requireNonNull(timeZone);
requireNonNull(locale);
requireNonNull(createdAt);
}
}
Not only do we never want to expose hashed passwords to clients of our API, we might also want to provide supplementary data (e.g. localized descriptions) or restrict visibility of fields based on the authenticated account's role or other permissions.
Here's how we'd like to expose the concept of an account to clients:
{
"accountId": "08d0ba3e-b19c-4317-a146-583860fcb5fd",
"roleId": "ADMINISTRATOR",
"name": "Example Administrator",
"emailAddress": "admin@soklet.com",
"timeZone": "America/New_York",
"timeZoneDescription": "Eastern Time",
"locale": "en-US",
"localeDescription": "English (United States)",
"createdAt": "2024-08-10T17:15:36.446321Z",
"createdAtDescription": "Aug 10, 2024, 1:15 PM"
}
{
"accountId": "08d0ba3e-b19c-4317-a146-583860fcb5fd",
"roleId": "ADMINISTRATOR",
"name": "Example Administrator",
"emailAddress": "admin@soklet.com",
"timeZone": "America/New_York",
"timeZoneDescription": "Eastern Time",
"locale": "en-US",
"localeDescription": "English (United States)",
"createdAt": "2024-08-10T17:15:36.446321Z",
"createdAtDescription": "Aug 10, 2024, 1:15 PM"
}
To accomplish this, we need to map our Account to a type suitable for marshaling to JSON.
The Toy Store App defines AccountResponse, which serves as the canonical representation of an account from the client's perspective:
// Field declarations and accessors elided for clarity
@ThreadSafe
public class AccountResponse {
// The concept of Guice's AssistedInject is described below
@AssistedInject
public AccountResponse(
// Guice injects this
@Nonnull Provider<CurrentContext> currentContextProvider,
// Caller provides this (an "assisted" value)
@Assisted @Nonnull Account account
) {
// Tailor our response based on current context.
// See "Context Awareness" section above.
CurrentContext currentContext = currentContextProvider.get();
Locale currentLocale = currentContext.getLocale();
ZoneId currentTimeZone = currentContext.getTimeZone();
this.accountId = account.accountId();
this.roleId = account.roleId();
this.name = account.name();
this.emailAddress = account.emailAddress();
this.locale = account.locale();
this.localeDescription = this.locale.getDisplayName(currentLocale);
this.timeZone = account.timeZone();
this.timeZoneDescription = this.timeZone.getDisplayName(TextStyle.FULL, currentLocale);
this.createdAt = account.createdAt();
this.createdAtDescription = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
.localizedBy(currentLocale)
.withZone(currentTimeZone)
.format(account.createdAt());
// It's often useful to customize the response
// based on who's looking at it.
//
// For example, if you're looking at someone else's record,
// you might see a subset of fields.
//
// But if you're looking at your own record,
// you might see everything available.
Account currentAccount = currentContext.getAccount().orElse(null);
if(currentAccount != null) {
if(currentAccount.accountId().equals(accountId)) {
// You might set extra fields
// if you're viewing your own account
}
if(currentAccount.roleId() == RoleId.ADMINISTRATOR) {
// You might set extra fields if an administrator
// is the one viewing this account
}
}
}
// This factory is how you create instances of AccountResponse
// using AssistedInject. See below for details.
@ThreadSafe
public interface AccountResponseFactory {
@Nonnull
AccountResponse create(@Nonnull Account account);
}
}
// Field declarations and accessors elided for clarity
@ThreadSafe
public class AccountResponse {
// The concept of Guice's AssistedInject is described below
@AssistedInject
public AccountResponse(
// Guice injects this
@Nonnull Provider<CurrentContext> currentContextProvider,
// Caller provides this (an "assisted" value)
@Assisted @Nonnull Account account
) {
// Tailor our response based on current context.
// See "Context Awareness" section above.
CurrentContext currentContext = currentContextProvider.get();
Locale currentLocale = currentContext.getLocale();
ZoneId currentTimeZone = currentContext.getTimeZone();
this.accountId = account.accountId();
this.roleId = account.roleId();
this.name = account.name();
this.emailAddress = account.emailAddress();
this.locale = account.locale();
this.localeDescription = this.locale.getDisplayName(currentLocale);
this.timeZone = account.timeZone();
this.timeZoneDescription = this.timeZone.getDisplayName(TextStyle.FULL, currentLocale);
this.createdAt = account.createdAt();
this.createdAtDescription = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
.localizedBy(currentLocale)
.withZone(currentTimeZone)
.format(account.createdAt());
// It's often useful to customize the response
// based on who's looking at it.
//
// For example, if you're looking at someone else's record,
// you might see a subset of fields.
//
// But if you're looking at your own record,
// you might see everything available.
Account currentAccount = currentContext.getAccount().orElse(null);
if(currentAccount != null) {
if(currentAccount.accountId().equals(accountId)) {
// You might set extra fields
// if you're viewing your own account
}
if(currentAccount.roleId() == RoleId.ADMINISTRATOR) {
// You might set extra fields if an administrator
// is the one viewing this account
}
}
}
// This factory is how you create instances of AccountResponse
// using AssistedInject. See below for details.
@ThreadSafe
public interface AccountResponseFactory {
@Nonnull
AccountResponse create(@Nonnull Account account);
}
}
Notice the @Assisted and @AssistedInject annotations. These are part of Guice's AssistedInject functionality.
The idea: we want to be able to instantiate a type on-demand, where some of its parameters are dependency-injected and some are manually provided at runtime. In this case, Guice is able to inject the currentContextProvider for you, but you need to "assist" by providing the account at runtime.
Let's look at an example - a Resource Method that vends an account.
@GET("/accounts/{accountId}")
public AccountResponseHolder findAccount(@PathParameter UUID accountId) {
// Look up the Account in our database
Account account = getAccountService().findAccountById(accountId).orElse(null);
if (account == null)
throw new NotFoundException();
// We only need to provide an Account to get an instance of AccountResponse.
// Guice handles injecting the rest of the constructor parameters for us.
AccountResponse accountResponse = getAccountResponseFactory().create(account);
// Wrap in a tiny record (below) to get the desired response format
return new AccountResponseHolder(accountResponse);
}
// Ensures response JSON looks like { "account": {...} }
public record AccountResponseHolder(
@Nonnull AccountResponse account
) {
requireNonNull(account);
}
@GET("/accounts/{accountId}")
public AccountResponseHolder findAccount(@PathParameter UUID accountId) {
// Look up the Account in our database
Account account = getAccountService().findAccountById(accountId).orElse(null);
if (account == null)
throw new NotFoundException();
// We only need to provide an Account to get an instance of AccountResponse.
// Guice handles injecting the rest of the constructor parameters for us.
AccountResponse accountResponse = getAccountResponseFactory().create(account);
// Wrap in a tiny record (below) to get the desired response format
return new AccountResponseHolder(accountResponse);
}
// Ensures response JSON looks like { "account": {...} }
public record AccountResponseHolder(
@Nonnull AccountResponse account
) {
requireNonNull(account);
}
We now have a context-sensitive canonical client representation of a Toy Store App account.
Revisiting the example from the Internationalization API Demonstration, if a client is configured for pt-BR (Brazilian Portuguese) and America/Sao_Paulo (São Paulo time, UTC-03:00), then the response is formatted correctly from the Paulista perspective:
{
"account": {
"accountId": "08d0ba3e-b19c-4317-a146-583860fcb5fd",
"roleId": "ADMINISTRATOR",
"name": "Example Administrator",
"emailAddress": "admin@soklet.com",
"timeZone": "America/New_York",
"timeZoneDescription": "Horário do Leste",
"locale": "en-US",
"localeDescription": "inglês (Estados Unidos)",
"createdAt": "2024-08-10T17:15:36.446321Z",
"createdAtDescription": "10 de ago. de 2024 14:15"
}
}
{
"account": {
"accountId": "08d0ba3e-b19c-4317-a146-583860fcb5fd",
"roleId": "ADMINISTRATOR",
"name": "Example Administrator",
"emailAddress": "admin@soklet.com",
"timeZone": "America/New_York",
"timeZoneDescription": "Horário do Leste",
"locale": "en-US",
"localeDescription": "inglês (Estados Unidos)",
"createdAt": "2024-08-10T17:15:36.446321Z",
"createdAtDescription": "10 de ago. de 2024 14:15"
}
}
See Internationalization to learn more about how the Toy Store App is designed to support a worldwide audience.
Authentication and Authorization
Generally speaking, all applications should have their own public-key cryptographic keypair (generally EdDSA for newer systems or RSA for legacy), and the Toy Store App is no exception. In addition to enabling asymmetric encryption, private halves of keypairs - with the assistance of hashing algorithms - are used to create unforgeable signatures, and public halves are used to verify the signatures.
Security Note
Production systems should store their keypairs in a secure and access-controlled location, e.g. AWS Secrets Manager or Azure Key Vault. Keypairs should be fetched from secure storage by the application at runtime.
The private half of a keypair should never be checked into source control unless it's not meant to be secure.
The Toy Store App breaks this rule and stores its keypair in a source-controlled location on the filesystem, but only to reduce complexity for sake of example.
Generating Keypairs
To generate a keypair, you don't need to install openssl or anything else on your system. Java provides platform-independent functionality out-of-the-box, no dependencies necessary:
// Generate an Ed25519 keypair
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("Ed25519");
KeyPair keyPair = keyPairGenerator.generateKeyPair();
// Write the public key's UTF-8 text representation to a file
String publicKeyAsString = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
Path publicKeyFile = Path.of("keypair.ed25519.public");
Files.writeString(publicKeyFile, publicKeyAsString, StandardCharsets.UTF_8);
// Write the private key's UTF-8 text representation to a file
String privateKeyAsString = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
Path privateKeyFile = Path.of("keypair.ed25519.private");
Files.writeString(privateKeyFile, privateKeyAsString, StandardCharsets.UTF_8);
// Generate an Ed25519 keypair
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("Ed25519");
KeyPair keyPair = keyPairGenerator.generateKeyPair();
// Write the public key's UTF-8 text representation to a file
String publicKeyAsString = Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
Path publicKeyFile = Path.of("keypair.ed25519.public");
Files.writeString(publicKeyFile, publicKeyAsString, StandardCharsets.UTF_8);
// Write the private key's UTF-8 text representation to a file
String privateKeyAsString = Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
Path privateKeyFile = Path.of("keypair.ed25519.private");
Files.writeString(privateKeyFile, privateKeyAsString, StandardCharsets.UTF_8);
Loading Keypairs
We reverse the process above. Again, this is no-dependency Java code:
// Prepare to read an Ed25519 keypair
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
// Read in the public key from a UTF-8 text file
Path publicKeyFile = Path.of("keypair.ed25519.public");
String publicKeyAsString = Files.readString(publicKeyFile, StandardCharsets.UTF_8);
EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyAsString));
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
// Read in the private key from a UTF-8 text file.
// Note: a real app would load from a secure location
// on the filesystem or a cloud platform's Secrets Manager
Path privateKeyFile = Path.of("keypair.ed25519.private");
String privateKeyAsString = Files.readString(privateKeyFile, StandardCharsets.UTF_8);
EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyAsString));
PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);
// We now have our reconstituted keypair
KeyPair keyPair = new KeyPair(publicKey, privateKey);
// Prepare to read an Ed25519 keypair
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
// Read in the public key from a UTF-8 text file
Path publicKeyFile = Path.of("keypair.ed25519.public");
String publicKeyAsString = Files.readString(publicKeyFile, StandardCharsets.UTF_8);
EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyAsString));
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
// Read in the private key from a UTF-8 text file.
// Note: a real app would load from a secure location
// on the filesystem or a cloud platform's Secrets Manager
Path privateKeyFile = Path.of("keypair.ed25519.private");
String privateKeyAsString = Files.readString(privateKeyFile, StandardCharsets.UTF_8);
EncodedKeySpec privateKeySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyAsString));
PrivateKey privateKey = keyFactory.generatePrivate(privateKeySpec);
// We now have our reconstituted keypair
KeyPair keyPair = new KeyPair(publicKey, privateKey);
Armed with our keypair, we can now perform public-key cryptography operations.
References:
JWT Handling
Public-key cryptography signature and verification workflows are useful for issuing and verifying JWTs: when a user authenticates with us, we want to be able to issue them a time-limited, unforgeable credential that asserts "this user is who they say they are".
First, we define an AccountJwt record type which holds application-specific account claims and supports signing them, verifying them, and encoding/decoding to the standard JWT web-safe string format for transmission.
There are many JWT libraries available, but it is straightforward to avoid a dependency and write your own instead - the standard JDK functionality performs the heavy lifting for you. We take this approach in the Toy Store App.
JWT Encoding
Here we construct a JWT given a subject and expiration, signed using the private half of our keypair:
// Output is a JWT that can be provided to an authenticated user
@Nonnull
public static String toStringRepresentation(
@Nonnull UUID accountId,
@Nonnull Instant expiration,
@Nonnull PrivateKey privateKey
) {
// We already have GSON as a dependency, so use it for JSON generation
String header = GSON.toJson(Map.of(
"alg", "HS512",
"typ", "JWT"
));
String payload = GSON.toJson(Map.of(
"sub", accountId,
"iat", expiration.toEpochMilli()
));
String encodedHeader = base64Encode(header.getBytes(StandardCharsets.UTF_8));
String encodedPayload = base64Encode(payload.getBytes(StandardCharsets.UTF_8));
byte[] signature = hmacSha512(format("%s.%s", encodedHeader, encodedPayload), privateKey);
String encodedSignature = base64Encode(signature);
return format("%s.%s.%s", encodedHeader, encodedPayload, encodedSignature);
}
// Helper for HMAC SHA-512 signed hashing
@Nonnull
private static byte[] hmacSha512(
@Nonnull String string,
@Nonnull PrivateKey privateKey
) {
try {
Mac hmacSha512 = Mac.getInstance("HmacSHA512");
SecretKeySpec secretKeySpec = new SecretKeySpec(privateKey.getEncoded(), privateKey.getAlgorithm());
hmacSha512.init(secretKeySpec);
return hmacSha512.doFinal(string.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalArgumentException(e);
}
}
// Helper for Base64-encoding
@Nonnull
private static String base64Encode(@Nonnull byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
// Output is a JWT that can be provided to an authenticated user
@Nonnull
public static String toStringRepresentation(
@Nonnull UUID accountId,
@Nonnull Instant expiration,
@Nonnull PrivateKey privateKey
) {
// We already have GSON as a dependency, so use it for JSON generation
String header = GSON.toJson(Map.of(
"alg", "HS512",
"typ", "JWT"
));
String payload = GSON.toJson(Map.of(
"sub", accountId,
"iat", expiration.toEpochMilli()
));
String encodedHeader = base64Encode(header.getBytes(StandardCharsets.UTF_8));
String encodedPayload = base64Encode(payload.getBytes(StandardCharsets.UTF_8));
byte[] signature = hmacSha512(format("%s.%s", encodedHeader, encodedPayload), privateKey);
String encodedSignature = base64Encode(signature);
return format("%s.%s.%s", encodedHeader, encodedPayload, encodedSignature);
}
// Helper for HMAC SHA-512 signed hashing
@Nonnull
private static byte[] hmacSha512(
@Nonnull String string,
@Nonnull PrivateKey privateKey
) {
try {
Mac hmacSha512 = Mac.getInstance("HmacSHA512");
SecretKeySpec secretKeySpec = new SecretKeySpec(privateKey.getEncoded(), privateKey.getAlgorithm());
hmacSha512.init(secretKeySpec);
return hmacSha512.doFinal(string.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new IllegalArgumentException(e);
}
}
// Helper for Base64-encoding
@Nonnull
private static String base64Encode(@Nonnull byte[] bytes) {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
The resulting JWT looks like this:
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwOGQwYmEzZS1iMTljLTQzMTctYTE0Ni01ODM4NjBmY2I1ZmQiLCJpYXQiOjE3MjE2NzEyNDM3Nzl9.rSiDIOEIBJVBrHTe7OmSQAkx0sWa3kFQzN_jA2JHqqvW4chZSfdnOBNEnXVJokezz5F6HPKWq9ub3A9LwDAqTg
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwOGQwYmEzZS1iMTljLTQzMTctYTE0Ni01ODM4NjBmY2I1ZmQiLCJpYXQiOjE3MjE2NzEyNDM3Nzl9.rSiDIOEIBJVBrHTe7OmSQAkx0sWa3kFQzN_jA2JHqqvW4chZSfdnOBNEnXVJokezz5F6HPKWq9ub3A9LwDAqTg
JWT Decoding and Verification
Now, let's decode a JWT. We take advantage of modern Java language features like pattern matching for switch and sealed classes and interfaces to represent our decoding outcomes as data rather than interrupting control flow by throwing different kinds of exceptions to indicate failure scenarios.
First, we define a sealed type that encapsulates all outcomes:
public sealed interface AccountJwtResult {
// Successfully decoded JWT and verified its signature. JWT has not expired
record Succeeded(@Nonnull AccountJwt accountJwt) implements AccountJwtResult {}
// JWT is structurally incorrect (e.g. missing a segment)
record InvalidStructure() implements AccountJwtResult {}
// JWT is incorrectly signed
record SignatureMismatch() implements AccountJwtResult {}
// Successfully decoded JWT and verified its signature, but JWT has expired
record Expired(@Nonnull AccountJwt accountJwt, @Nonnull Instant expiredAt) implements AccountJwtResult {}
// Successfully decoded JWT and verified its signature, but some claims are missing
record MissingClaims(@Nonnull Set<String> claims) implements AccountJwtResult {}
// Successfully decoded JWT and verified its signature, but some claims are invalid
record InvalidClaims(@Nonnull Set<String> claims) implements AccountJwtResult {}
}
public sealed interface AccountJwtResult {
// Successfully decoded JWT and verified its signature. JWT has not expired
record Succeeded(@Nonnull AccountJwt accountJwt) implements AccountJwtResult {}
// JWT is structurally incorrect (e.g. missing a segment)
record InvalidStructure() implements AccountJwtResult {}
// JWT is incorrectly signed
record SignatureMismatch() implements AccountJwtResult {}
// Successfully decoded JWT and verified its signature, but JWT has expired
record Expired(@Nonnull AccountJwt accountJwt, @Nonnull Instant expiredAt) implements AccountJwtResult {}
// Successfully decoded JWT and verified its signature, but some claims are missing
record MissingClaims(@Nonnull Set<String> claims) implements AccountJwtResult {}
// Successfully decoded JWT and verified its signature, but some claims are invalid
record InvalidClaims(@Nonnull Set<String> claims) implements AccountJwtResult {}
}
Then, we implement a decoding function which vends the appropriate outcome:
@Nonnull
public static AccountJwtResult fromStringRepresentation(
@Nonnull String string,
@Nonnull PrivateKey privateKey
) {
String[] components = string.trim().split("\\.");
// Outcome: invalid structure
if (components.length != 3)
return new AccountJwtResult.InvalidStructure();
String encodedHeader = components[0];
String encodedPayload = components[1];
String encodedSignature = components[2];
String decodedPayload = new String(base64Decode(encodedPayload), StandardCharsets.UTF_8);
byte[] decodedSignature = base64Decode(encodedSignature);
byte[] expectedSignature = hmacSha512(format("%s.%s", encodedHeader, encodedPayload), privateKey);
// Outcome: invalid signature
if (!Arrays.equals(expectedSignature, decodedSignature))
return new AccountJwtResult.SignatureMismatch();
// A more rigorous implementation would catch JSON decoding
// exceptions and surface as an AccountJwtResult.
// Because this is example code, we keep it simple for clarity
Map<String, Object> decodedPayloadAsMap = GSON.fromJson(decodedPayload, Map.class);
String subAsString = (String) decodedPayloadAsMap.get("sub");
Number iatAsNumber = (Number) decodedPayloadAsMap.get("iat");
Set<String> missingClaims = new HashSet<>();
if (subAsString == null)
missingClaims.add("sub");
if (iatAsNumber == null)
missingClaims.add("iat");
// Outcome: missing claims
if (missingClaims.size() > 0)
return new AccountJwtResult.MissingClaims(missingClaims);
// Outcome: invalid claims
try {
UUID.fromString(subAsString);
} catch (IllegalArgumentException ignored) {
return new AccountJwtResult.InvalidClaims(Set.of("sub"));
}
UUID sub = UUID.fromString(subAsString);
Instant iat = Instant.ofEpochMilli(iatAsNumber.longValue());
AccountJwt accountJwt = new AccountJwt(sub, iat);
// Outcome: expired
if (iat.isBefore(Instant.now()))
return new AccountJwtResult.Expired(accountJwt, iat);
// Outcome: success
return new AccountJwtResult.Succeeded(accountJwt);
}
@Nonnull
public static AccountJwtResult fromStringRepresentation(
@Nonnull String string,
@Nonnull PrivateKey privateKey
) {
String[] components = string.trim().split("\\.");
// Outcome: invalid structure
if (components.length != 3)
return new AccountJwtResult.InvalidStructure();
String encodedHeader = components[0];
String encodedPayload = components[1];
String encodedSignature = components[2];
String decodedPayload = new String(base64Decode(encodedPayload), StandardCharsets.UTF_8);
byte[] decodedSignature = base64Decode(encodedSignature);
byte[] expectedSignature = hmacSha512(format("%s.%s", encodedHeader, encodedPayload), privateKey);
// Outcome: invalid signature
if (!Arrays.equals(expectedSignature, decodedSignature))
return new AccountJwtResult.SignatureMismatch();
// A more rigorous implementation would catch JSON decoding
// exceptions and surface as an AccountJwtResult.
// Because this is example code, we keep it simple for clarity
Map<String, Object> decodedPayloadAsMap = GSON.fromJson(decodedPayload, Map.class);
String subAsString = (String) decodedPayloadAsMap.get("sub");
Number iatAsNumber = (Number) decodedPayloadAsMap.get("iat");
Set<String> missingClaims = new HashSet<>();
if (subAsString == null)
missingClaims.add("sub");
if (iatAsNumber == null)
missingClaims.add("iat");
// Outcome: missing claims
if (missingClaims.size() > 0)
return new AccountJwtResult.MissingClaims(missingClaims);
// Outcome: invalid claims
try {
UUID.fromString(subAsString);
} catch (IllegalArgumentException ignored) {
return new AccountJwtResult.InvalidClaims(Set.of("sub"));
}
UUID sub = UUID.fromString(subAsString);
Instant iat = Instant.ofEpochMilli(iatAsNumber.longValue());
AccountJwt accountJwt = new AccountJwt(sub, iat);
// Outcome: expired
if (iat.isBefore(Instant.now()))
return new AccountJwtResult.Expired(accountJwt, iat);
// Outcome: success
return new AccountJwtResult.Succeeded(accountJwt);
}
Put it all together, and we have strongly-typed JWTs.
Create one like this:
UUID accountId = ... // get account ID from elsewhere
Instant expiration = Instant.now().plus(60, ChronoUnit.MINUTES);
AccountJwt accountJwt = new AccountJwt(accountId, expiration);
// Convert the JWT to a string representation
PrivateKey privateKey = ... // get private key from elsewhere
String jwtAsString = accountJwt.toStringRepresentation(privateKey);
UUID accountId = ... // get account ID from elsewhere
Instant expiration = Instant.now().plus(60, ChronoUnit.MINUTES);
AccountJwt accountJwt = new AccountJwt(accountId, expiration);
// Convert the JWT to a string representation
PrivateKey privateKey = ... // get private key from elsewhere
String jwtAsString = accountJwt.toStringRepresentation(privateKey);
And parse one like this:
String jwtAsString = "eyJhb...riwKE"; // Real value elided
PrivateKey privateKey = ... // get private key from elsewhere
// Parse the JWT string representation back into our Java record type.
// We take advantage of Java's pattern matching with switch here
switch (AccountJwt.fromStringRepresentation(jwtAsString, privateKey)) {
case Succeeded(@Nonnull AccountJwt accountJwt) -> {
// Do something with our JWT
}
case Expired(@Nonnull AccountJwt accountJwt, @Nonnull Instant expiredAt) -> {
// Handle an expired JWT
}
case SignatureMismatch() -> {
// Handle an invalid signature
}
default -> {
// JWT is bad for some other reason
}
}
String jwtAsString = "eyJhb...riwKE"; // Real value elided
PrivateKey privateKey = ... // get private key from elsewhere
// Parse the JWT string representation back into our Java record type.
// We take advantage of Java's pattern matching with switch here
switch (AccountJwt.fromStringRepresentation(jwtAsString, privateKey)) {
case Succeeded(@Nonnull AccountJwt accountJwt) -> {
// Do something with our JWT
}
case Expired(@Nonnull AccountJwt accountJwt, @Nonnull Instant expiredAt) -> {
// Handle an expired JWT
}
case SignatureMismatch() -> {
// Handle an invalid signature
}
default -> {
// JWT is bad for some other reason
}
}
Annotations
Many systems benefit from the coarse-grained concept of "this API call requires an authenticated account" or "this API call requires an authenticated account with a role in the set (...)" which can be applied declaratively.
To acheive this, the Toy Store App defines an @AuthorizationRequired annotation which can be applied to Resource Methods. This lets us decorate them with metadata that indicates what role[s] are required for invocation, making permissions clear at-a-glance.
// Our annotation has its data accessible at runtime.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthorizationRequired {
@Nullable RoleId[] value() default {};
}
// Our set of roles looks like this:
public enum RoleId {
CUSTOMER,
EMPLOYEE,
ADMINISTRATOR
}
// Our annotation has its data accessible at runtime.
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthorizationRequired {
@Nullable RoleId[] value() default {};
}
// Our set of roles looks like this:
public enum RoleId {
CUSTOMER,
EMPLOYEE,
ADMINISTRATOR
}
The @AuthorizationRequired can now be applied to Resource Methods:
@Nonnull
@AuthorizationRequired({RoleId.EMPLOYEE, RoleId.ADMINISTRATOR})
@POST("/toys")
public ToyResponseHolder createToy(
@Nonnull @RequestBody ToyCreateRequest request
) {
// Elided: toy creation
}
@Nonnull
@AuthorizationRequired({RoleId.EMPLOYEE, RoleId.ADMINISTRATOR})
@POST("/toys")
public ToyResponseHolder createToy(
@Nonnull @RequestBody ToyCreateRequest request
) {
// Elided: toy creation
}
The semantics of @AuthorizationRequired as applied to a Resource Method are:
- If present, authentication is required
- If present and one or more roles are specified, authorize the presence of at least one role for the authenticated account
We implement that here via a custom implementation of Soklet's LifecycleInterceptor construct:
// Let's wrap our Resource Method invocations to apply authentication
// context and perform authorization checks
SokletConfig config = SokletConfig.withServer(...)
.lifecycleInterceptor(new LifecycleInterceptor() {
@Override
public void interceptRequest(
@Nonnull Request request,
@Nullable ResourceMethod resourceMethod,
@Nonnull Function<Request, MarshaledResponse> responseGenerator,
@Nonnull Consumer<MarshaledResponse> responseWriter
) {
Account account = null;
// Part 1: Try to pull a JWT from request headers.
// If it exists, see if we can pull an account from it.
String authenticationToken = request.getHeader("X-Authentication-Token").orElse(null);
if (authenticationToken != null) {
// Parse the JWT...
AccountJwtResult accountJwtResult = AccountJwt.fromStringRepresentation(
authenticationToken,
keyPair.getPrivate()
);
// ...and, if it's valid, load the account it points to.
switch (accountJwtResult) {
case Succeeded(@Nonnull AccountJwt accountJwt) -> {
account = accountService.findAccountById(accountJwt.accountId()).orElse(null);
}
case Expired(@Nonnull AccountJwt accountJwt, @Nonnull Instant expiredAt) -> {
logger.debug("JWT for account ID {} expired at {}", accountJwt.accountId(), expiredAt);
}
case SignatureMismatch() -> {
logger.warn("JWT signature is invalid: {}", authenticationToken);
}
default -> {
logger.warn("JWT is invalid: {}", authenticationToken);
}
}
}
// Part 2: See if the Resource Method has an @AuthorizationRequired annotation.
// If so, perform authentication/authorization checks
if (resourceMethod != null) {
AuthorizationRequired authorizationRequired = resourceMethod.getMethod().getAnnotation(AuthorizationRequired.class);
if (authorizationRequired != null) {
// Ensure an account was found for the authentication token.
// AuthenticationException is a custom type, see "Error Handling" documentation
if (account == null)
throw new AuthenticationException();
Set<RoleId> requiredRoleIds = authorizationRequired.value() == null
? Set.of() : Arrays.stream(authorizationRequired.value()).collect(Collectors.toSet());
// If any roles were specified, ensure the account has as least one.
// AuthorizationException is a custom type, see "Error Handling" documentation
if (requiredRoleIds.size() > 0 && !requiredRoleIds.contains(account.roleId()))
throw new AuthorizationException();
}
}
// Part 3: Create a new current context scope with the authenticated account (if present)
CurrentContext currentContext = CurrentContext.withRequest(request)
.account(account)
.build();
currentContext.run(() -> {
// Finally, let downstream processing proceed
MarshaledResponse marshaledResponse = responseGenerator.apply(request);
responseWriter.accept(marshaledResponse);
});
}
// Rest of LifecycleInterceptor elided
}).build();
// Let's wrap our Resource Method invocations to apply authentication
// context and perform authorization checks
SokletConfig config = SokletConfig.withServer(...)
.lifecycleInterceptor(new LifecycleInterceptor() {
@Override
public void interceptRequest(
@Nonnull Request request,
@Nullable ResourceMethod resourceMethod,
@Nonnull Function<Request, MarshaledResponse> responseGenerator,
@Nonnull Consumer<MarshaledResponse> responseWriter
) {
Account account = null;
// Part 1: Try to pull a JWT from request headers.
// If it exists, see if we can pull an account from it.
String authenticationToken = request.getHeader("X-Authentication-Token").orElse(null);
if (authenticationToken != null) {
// Parse the JWT...
AccountJwtResult accountJwtResult = AccountJwt.fromStringRepresentation(
authenticationToken,
keyPair.getPrivate()
);
// ...and, if it's valid, load the account it points to.
switch (accountJwtResult) {
case Succeeded(@Nonnull AccountJwt accountJwt) -> {
account = accountService.findAccountById(accountJwt.accountId()).orElse(null);
}
case Expired(@Nonnull AccountJwt accountJwt, @Nonnull Instant expiredAt) -> {
logger.debug("JWT for account ID {} expired at {}", accountJwt.accountId(), expiredAt);
}
case SignatureMismatch() -> {
logger.warn("JWT signature is invalid: {}", authenticationToken);
}
default -> {
logger.warn("JWT is invalid: {}", authenticationToken);
}
}
}
// Part 2: See if the Resource Method has an @AuthorizationRequired annotation.
// If so, perform authentication/authorization checks
if (resourceMethod != null) {
AuthorizationRequired authorizationRequired = resourceMethod.getMethod().getAnnotation(AuthorizationRequired.class);
if (authorizationRequired != null) {
// Ensure an account was found for the authentication token.
// AuthenticationException is a custom type, see "Error Handling" documentation
if (account == null)
throw new AuthenticationException();
Set<RoleId> requiredRoleIds = authorizationRequired.value() == null
? Set.of() : Arrays.stream(authorizationRequired.value()).collect(Collectors.toSet());
// If any roles were specified, ensure the account has as least one.
// AuthorizationException is a custom type, see "Error Handling" documentation
if (requiredRoleIds.size() > 0 && !requiredRoleIds.contains(account.roleId()))
throw new AuthorizationException();
}
}
// Part 3: Create a new current context scope with the authenticated account (if present)
CurrentContext currentContext = CurrentContext.withRequest(request)
.account(account)
.build();
currentContext.run(() -> {
// Finally, let downstream processing proceed
MarshaledResponse marshaledResponse = responseGenerator.apply(request);
responseWriter.accept(marshaledResponse);
});
}
// Rest of LifecycleInterceptor elided
}).build();
Now, Resource Methods have their @AuthorizationRequired annotations respected, and downstream code can ask the CurrentContext for the authenticated account:
@GET("/example")
@AuthorizationRequired(RoleId.ADMINISTRATOR)
public void example() {
// Current context has our account
Account account = getCurrentContext().getAccount().get();
}
@GET("/example")
@AuthorizationRequired(RoleId.ADMINISTRATOR)
public void example() {
// Current context has our account
Account account = getCurrentContext().getAccount().get();
}
References:
RoleAuthenticationExceptionAuthorizationExceptionCurrentContextLifecycleInterceptor@AuthorizationRequired
Password Management
Many modern applications delegate password management to third-party vendors, e.g. Amazon Cognito. However, The Toy Store App manages its own passwords - it stores and verifies them via PBKDF2, which is recommended for password hashing by RFC 8018 as of 2017.
Similar to how the Toy Store App handles JWT Encoding and Decoding, standard JDK functionality performs the heavy cryptographic lifting - you do not need to add a dependency to your system to implement password-related crypto unless you have specialized requirements. The Toy Store App's password hashing and verification is encapsulated by the PasswordManager type.
Password Hashing
Given a plaintext password, we generate a value that includes its hashed representation as well as information about how the hash was generated (algorithm, salt, etc.) This value - never the plaintext - would be saved in a datastore and associated with an account. If an attacker were to gain access to the datastore, it would be computationally infeasible for her to "unhash" passwords back to potential plaintext equivalents, e.g. with a rainbow table.
@Nonnull
public String hashPassword(@Nonnull String plaintextPassword) {
// These can be adjusted to trade off "difficulty to break" vs. performance as desired
final String RNG_ALGORITHM = "SHA1PRNG";
final String HASH_ALGORITHM = "PBKDF2WithHmacSHA512";
final int ITERATIONS = 210_000; // OWASP 2023 recommendation
final int SALT_LENGTH = 64;
final int KEY_LENGTH = 128 * 16;
try {
// Generate the salt
byte[] salt = new byte[SALT_LENGTH];
SecureRandom secureRandom = SecureRandom.getInstance(RNG_ALGORITHM);
secureRandom.nextBytes(salt);
// Generate the hash
PBEKeySpec keySpec = new PBEKeySpec(plaintextPassword.toCharArray(), salt, ITERATIONS, KEY_LENGTH);
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(HASH_ALGORITHM);
byte[] hashedPassword = secretKeyFactory.generateSecret(keySpec).getEncoded();
// Generate a string of the form:
// <hash algorithm>:<iterations>:<key length>:<salt>:<hashed password>
return format("%s:%d:%d:%s:%s", HASH_ALGORITHM, ITERATIONS, KEY_LENGTH, base64Encode(salt), base64Encode(hashedPassword));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
}
@Nonnull
protected String base64Encode(@Nonnull byte[] bytes) {
return Base64.getEncoder().withoutPadding().encodeToString(bytes);
}
@Nonnull
public String hashPassword(@Nonnull String plaintextPassword) {
// These can be adjusted to trade off "difficulty to break" vs. performance as desired
final String RNG_ALGORITHM = "SHA1PRNG";
final String HASH_ALGORITHM = "PBKDF2WithHmacSHA512";
final int ITERATIONS = 210_000; // OWASP 2023 recommendation
final int SALT_LENGTH = 64;
final int KEY_LENGTH = 128 * 16;
try {
// Generate the salt
byte[] salt = new byte[SALT_LENGTH];
SecureRandom secureRandom = SecureRandom.getInstance(RNG_ALGORITHM);
secureRandom.nextBytes(salt);
// Generate the hash
PBEKeySpec keySpec = new PBEKeySpec(plaintextPassword.toCharArray(), salt, ITERATIONS, KEY_LENGTH);
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(HASH_ALGORITHM);
byte[] hashedPassword = secretKeyFactory.generateSecret(keySpec).getEncoded();
// Generate a string of the form:
// <hash algorithm>:<iterations>:<key length>:<salt>:<hashed password>
return format("%s:%d:%d:%s:%s", HASH_ALGORITHM, ITERATIONS, KEY_LENGTH, base64Encode(salt), base64Encode(hashedPassword));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
}
@Nonnull
protected String base64Encode(@Nonnull byte[] bytes) {
return Base64.getEncoder().withoutPadding().encodeToString(bytes);
}
Password Verification
Given a plaintext password and the above hashed representation, we must be able to determine if the plaintext matches the hash.
In the Toy Store App, clients supply an email address and plaintext password to authenticate an account. The App loads the account record that matches the email address from the database (including its hashed password), and then passes the plaintext and hashed values to this method to verify they match:
@Nonnull
public Boolean verifyPassword(
@Nonnull String plaintextPassword,
@Nonnull String hashedPassword
) {
// Hashed password is a string of the form:
// <hash algorithm>:<iterations>:<key length>:<salt>:<hashed password>
String[] components = hashedPassword.split(":");
String hashAlgorithm = components[0];
int iterations = Integer.parseInt(components[1]);
int keyLength = Integer.parseInt(components[2]);
byte[] salt = base64Decode(components[3]);
byte[] hashedPasswordComponent = base64Decode(components[4]);
try {
PBEKeySpec keySpec = new PBEKeySpec(plaintextPassword.toCharArray(), salt, iterations, keyLength);
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(hashAlgorithm);
byte[] comparisonHash = secretKeyFactory.generateSecret(keySpec).getEncoded();
return Arrays.equals(hashedPasswordComponent, comparisonHash);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
}
@Nonnull
protected byte[] base64Decode(@Nonnull String string) {
return Base64.getDecoder().decode(string);
}
@Nonnull
public Boolean verifyPassword(
@Nonnull String plaintextPassword,
@Nonnull String hashedPassword
) {
// Hashed password is a string of the form:
// <hash algorithm>:<iterations>:<key length>:<salt>:<hashed password>
String[] components = hashedPassword.split(":");
String hashAlgorithm = components[0];
int iterations = Integer.parseInt(components[1]);
int keyLength = Integer.parseInt(components[2]);
byte[] salt = base64Decode(components[3]);
byte[] hashedPasswordComponent = base64Decode(components[4]);
try {
PBEKeySpec keySpec = new PBEKeySpec(plaintextPassword.toCharArray(), salt, iterations, keyLength);
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(hashAlgorithm);
byte[] comparisonHash = secretKeyFactory.generateSecret(keySpec).getEncoded();
return Arrays.equals(hashedPasswordComponent, comparisonHash);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
}
@Nonnull
protected byte[] base64Decode(@Nonnull String string) {
return Base64.getDecoder().decode(string);
}
Here's how the full authentication process looks in the Toy Store App - if the email/password combination is authenticated, a JWT is generated:
@Nonnull
public AccountJwt authenticateAccount(
@Nonnull AccountAuthenticateRequest request
) {
String emailAddress = trimToNull(request.emailAddress());
String password = trimToNull(request.password());
Map<String, String> fieldErrors = new LinkedHashMap<>();
if (emailAddress == null)
fieldErrors.put("emailAddress", getStrings().get("Email address is required."));
if (password == null)
fieldErrors.put("password", getStrings().get("Password is required."));
if (fieldErrors.size() > 0)
throw ApplicationException.withStatusCode(422)
.fieldErrors(fieldErrors)
.build();
Account account = getDatabase().executeForObject("""
SELECT *
FROM account
WHERE email_address=?
""", Account.class, emailAddress.toLowerCase(Locale.US)).orElse(null);
// Reject if no account, or account's hashed password does not match
if (account == null || !getPasswordManager().verifyPassword(password, account.password()))
throw ApplicationException.withStatusCode(401)
.generalError(getStrings().get("Sorry, we could not authenticate you."))
.build();
// Generate a JWT
Instant expiration = Instant.now().plus(60, ChronoUnit.MINUTES);
return new AccountJwt(account.accountId(), expiration);
}
@Nonnull
public AccountJwt authenticateAccount(
@Nonnull AccountAuthenticateRequest request
) {
String emailAddress = trimToNull(request.emailAddress());
String password = trimToNull(request.password());
Map<String, String> fieldErrors = new LinkedHashMap<>();
if (emailAddress == null)
fieldErrors.put("emailAddress", getStrings().get("Email address is required."));
if (password == null)
fieldErrors.put("password", getStrings().get("Password is required."));
if (fieldErrors.size() > 0)
throw ApplicationException.withStatusCode(422)
.fieldErrors(fieldErrors)
.build();
Account account = getDatabase().executeForObject("""
SELECT *
FROM account
WHERE email_address=?
""", Account.class, emailAddress.toLowerCase(Locale.US)).orElse(null);
// Reject if no account, or account's hashed password does not match
if (account == null || !getPasswordManager().verifyPassword(password, account.password()))
throw ApplicationException.withStatusCode(401)
.generalError(getStrings().get("Sorry, we could not authenticate you."))
.build();
// Generate a JWT
Instant expiration = Instant.now().plus(60, ChronoUnit.MINUTES);
return new AccountJwt(account.accountId(), expiration);
}
References:
Internationalization
Modern web systems should be designed with internationalization (i18n) in mind. The JDK has extensive built-in support for the following across most of the planet's locales:
- Number formatting (counting, currency, percentages)
- Date/time manipulation and display (time zones, calendar systems)
Additionally, the Toy Store App uses Lokalized, Soklet's zero-dependency sister library, to manage user-facing string translations.
Web systems that do not take i18n into account (e.g. ignoring time zones) are throwing away context at best and incorrectly modeling at worst - retrofitting later can be expensive. Building for a global audience from the beginning requires marginal effort and offers significant rewards.
The Toy Store App takes full advantage of Context-Awareness to always know the appropriate Locale and ZoneId for the current thread of execution - no matter where we are in the code, we can localize appropriately.
Internationalization is not just the responsibility of a system's front-end; it should be built into the backend for a uniform user experience.
Locale and Time Zone
TODO
User-Facing Strings
TODO
// Information about our toy and the credit card used to purchase it
String creditCardNumber = ...; // e.g. "4111111111111111"
BigDecimal price = ...; // e.g. new BigDecimal("12.34")
Currency currency = ...; // e.g. Currency.getInstance("USD");
try {
creditCardProcessor.makePayment(creditCardNumber, price, currency);
} catch (CreditCardPaymentException e) {
// Get a handle to our current context so we can localize
CurrentContext currentContext = getCurrentContext();
Locale locale = currentContext.getLocale();
// Have the JDK format the toy's price + currency
// by supplying the appropriate locale
NumberFormat formatter = NumberFormat.getCurrencyInstance(locale);
formatter.setCurrency(currency);
// Construct our localized error message.
// Because our Strings instance relies on CurrentContext
// to load the appropriate file, we don't need to supply a locale here
String generalError = getStrings().get(
"We were unable to charge {{amount}} to your credit card.",
Map.of("amount", formatter.format(price))
);
// Throw an HTTP 422
throw ApplicationException.withStatusCode(422)
.generalError(generalError)
.build();
}
// Information about our toy and the credit card used to purchase it
String creditCardNumber = ...; // e.g. "4111111111111111"
BigDecimal price = ...; // e.g. new BigDecimal("12.34")
Currency currency = ...; // e.g. Currency.getInstance("USD");
try {
creditCardProcessor.makePayment(creditCardNumber, price, currency);
} catch (CreditCardPaymentException e) {
// Get a handle to our current context so we can localize
CurrentContext currentContext = getCurrentContext();
Locale locale = currentContext.getLocale();
// Have the JDK format the toy's price + currency
// by supplying the appropriate locale
NumberFormat formatter = NumberFormat.getCurrencyInstance(locale);
formatter.setCurrency(currency);
// Construct our localized error message.
// Because our Strings instance relies on CurrentContext
// to load the appropriate file, we don't need to supply a locale here
String generalError = getStrings().get(
"We were unable to charge {{amount}} to your credit card.",
Map.of("amount", formatter.format(price))
);
// Throw an HTTP 422
throw ApplicationException.withStatusCode(422)
.generalError(generalError)
.build();
}
TODO
If a client with locale pt-BR attempted and failed to purchase a toy worth $124.99 in currency USD, the JSON returned would look like this:
{
"summary": "Não foi possível cobrar US$ 124,99 no seu cartão de crédito.",
"generalErrors": [
"Não foi possível cobrar US$ 124,99 no seu cartão de crédito."
],
"fieldErrors": {},
"metadata": {}
}
{
"summary": "Não foi possível cobrar US$ 124,99 no seu cartão de crédito.",
"generalErrors": [
"Não foi possível cobrar US$ 124,99 no seu cartão de crédito."
],
"fieldErrors": {},
"metadata": {}
}
TODO
@Nonnull
@Provides
@Singleton
public Strings provideStrings(@Nonnull Provider<CurrentContext> currentContextProvider) {
String defaultLanguageCode = Configuration.getDefaultLocale().getLanguage();
return new DefaultStrings.Builder(defaultLanguageCode,
// Look in the "strings" directory for localization files
() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("strings"))
)
// Rely on the current context's preferred locale
// to pick the appropriate localization file
.localeSupplier(() -> currentContextProvider.get().getLocale())
.build();
}
@Nonnull
@Provides
@Singleton
public Strings provideStrings(@Nonnull Provider<CurrentContext> currentContextProvider) {
String defaultLanguageCode = Configuration.getDefaultLocale().getLanguage();
return new DefaultStrings.Builder(defaultLanguageCode,
// Look in the "strings" directory for localization files
() -> LocalizedStringLoader.loadFromFilesystem(Paths.get("strings"))
)
// Rely on the current context's preferred locale
// to pick the appropriate localization file
.localeSupplier(() -> currentContextProvider.get().getLocale())
.build();
}
See Error Handling for details on how the Toy Store App surfaces errors to clients.
Here is a set of pt strings:
{
"Hello, world!": "Olá Mundo!",
"Sorry, we could not authenticate you.": "Desculpe, não foi possível autenticá-lo.",
"Illegal value '{{parameterValue}}' specified for query parameter '{{parameterName}}'": "Valor ilegal '{{parameterValue}}' especificado para o parâmetro de consulta '{{parameterName}}'",
"[not provided]": "[não fornecido]",
"You must be authenticated to perform this action.": "Você deve estar autenticado para executar esta ação.",
"You are not authorized to perform this action.": "Você não está autorizado a executar esta ação.",
"An unexpected error occurred.": "Um erro inesperado ocorreu.",
"Your request was improperly formatted.": "Sua solicitação foi formatada incorretamente.",
"The resource you requested was not found.": "O recurso que você solicitou não foi encontrado.",
"Name is required.": "O nome é obrigatório.",
"Price is required.": "O preço é obrigatório.",
"Price cannot be negative.": "O preço não pode ser negativo.",
"Currency is required.": "A moeda é obrigatória.",
"There is already a toy named '{{name}}'.": "Já existe um brinquedo chamado '{{name}}'.",
"Account ID is required.": "O identificador da conta é obrigatório.",
"Credit card number is required.": "O número do cartão de crédito é obrigatório.",
"Credit card expiration is required.": "A expiração do cartão de crédito é obrigatória.",
"Credit card is expired.": "O cartão de crédito expirou.",
"We were unable to charge {{amount}} to your credit card.": "Não foi possível cobrar {{amount}} no seu cartão de crédito.",
"Email address is required.": "É necessário um endereço de e-mail.",
"Password is required.": "Senha requerida."
}
{
"Hello, world!": "Olá Mundo!",
"Sorry, we could not authenticate you.": "Desculpe, não foi possível autenticá-lo.",
"Illegal value '{{parameterValue}}' specified for query parameter '{{parameterName}}'": "Valor ilegal '{{parameterValue}}' especificado para o parâmetro de consulta '{{parameterName}}'",
"[not provided]": "[não fornecido]",
"You must be authenticated to perform this action.": "Você deve estar autenticado para executar esta ação.",
"You are not authorized to perform this action.": "Você não está autorizado a executar esta ação.",
"An unexpected error occurred.": "Um erro inesperado ocorreu.",
"Your request was improperly formatted.": "Sua solicitação foi formatada incorretamente.",
"The resource you requested was not found.": "O recurso que você solicitou não foi encontrado.",
"Name is required.": "O nome é obrigatório.",
"Price is required.": "O preço é obrigatório.",
"Price cannot be negative.": "O preço não pode ser negativo.",
"Currency is required.": "A moeda é obrigatória.",
"There is already a toy named '{{name}}'.": "Já existe um brinquedo chamado '{{name}}'.",
"Account ID is required.": "O identificador da conta é obrigatório.",
"Credit card number is required.": "O número do cartão de crédito é obrigatório.",
"Credit card expiration is required.": "A expiração do cartão de crédito é obrigatória.",
"Credit card is expired.": "O cartão de crédito expirou.",
"We were unable to charge {{amount}} to your credit card.": "Não foi possível cobrar {{amount}} no seu cartão de crédito.",
"Email address is required.": "É necessário um endereço de e-mail.",
"Password is required.": "Senha requerida."
}
Error Handling
TODO
Configuration
TODO
Automated Testing
TODO
Unit Tests
TODO
Integration Tests
TODO (this is linked to from Automated Testing section)

