Get started with Java
A bookshop server with four operations, in plain Java with no framework. The finished project is examples/java. You need JDK 21. Using Spring Boot? The Spring Boot guide builds the same server with annotated beans.
1. Create the project
A Maven project with the Rayfold dependency, Nimbus to verify tokens, JUnit and rayfold-test for the tests, and the exec plugin that runs the server. pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>bookshop</artifactId>
<version>0.1.0-SNAPSHOT</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- a property rather than plugin configuration, so -Dexec.mainClass=...Auth can run the token printer instead -->
<exec.mainClass>com.example.bookshop.Bookshop</exec.mainClass>
</properties>
<dependencies>
<dependency>
<groupId>dev.rayfold</groupId>
<artifactId>rayfold-java</artifactId>
<version>0.2.3</version>
</dependency>
<!-- verifies the tokens your identity provider signs -->
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>10.9.1</version>
</dependency>
<!-- calls the server from a unit test, with no network between them -->
<dependency>
<groupId>dev.rayfold</groupId>
<artifactId>rayfold-test</artifactId>
<version>0.2.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.4</version>
</plugin>
</plugins>
</build>
</project>Since 0.2.2
rayfold-test and the server's .clock(...) came with 0.2.2. On 0.2.1, leave out the rayfold-test dependency, and in Bookshop.java the server(store, now) overload with its .clock(now) line.
2. Describe the API
Save the schema as src/main/resources/bookshop.rayfold:
entity Author {
id: ID
name: String
}
entity Book @cache(maxAge: 60s, scope: public) {
id: ID
title: String
stock: Int
author: Author
"""What the shop paid. Only staff can see it."""
costPrice: Decimal? @allow(read: viewer.role == "staff")
}
error OutOfStock { bookId: ID, available: Int }
event StockChanged { bookId: ID, stock: Int }
query book(id: ID): Book?
query books(page: PageArgs = { first: 20 }): Page<Book>
"""Take copies off the shelf. Fails if there are not enough."""
command buy(bookId: ID, qty: Int = 1 @range(min: 1, max: 10)): Book
throws OutOfStock
emits StockChanged
@allow(write: viewer != null)
command restock(bookId: ID, qty: Int @range(min: 1, max: 1000)): Book
emits StockChanged
@allow(write: viewer.role == "staff")The server reads it at startup and checks every request against it. npx @rayfold/cli gen java generates records from it if you want them.
3. Write the resolvers
The books are records, kept in memory by Store.java:
package com.example.bookshop;
import java.math.BigDecimal;
public record Book(String id, String title, int stock, String authorId, BigDecimal costPrice) {
public Book withStock(int stock) {
return new Book(id, title, stock, authorId, costPrice);
}
}package com.example.bookshop;
public record Author(String id, String name) {}package com.example.bookshop;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.UnaryOperator;
/** The shop's shelves, in memory. Each server gets a fresh one. */
public final class Store {
private final Map<String, Author> authors = Map.of(
"a1", new Author("a1", "Ursula K. Le Guin"),
"a2", new Author("a2", "Frank Herbert"));
private final Map<String, Book> books = new ConcurrentHashMap<>(Map.of(
"b1", new Book("b1", "A Wizard of Earthsea", 3, "a1", new BigDecimal("4.20")),
"b2", new Book("b2", "The Left Hand of Darkness", 0, "a1", new BigDecimal("5.10")),
"b3", new Book("b3", "Dune", 7, "a2", new BigDecimal("6.00"))));
private final AtomicInteger authorLookups = new AtomicInteger();
public Optional<Book> book(String id) {
return Optional.ofNullable(books.get(id));
}
public List<Book> books() {
return books.values().stream().sorted(Comparator.comparing(Book::id)).toList();
}
/** Many authors in one lookup, the way one {@code WHERE id IN (...)} query would fetch them. */
public List<Author> authors(List<String> ids) {
authorLookups.incrementAndGet();
return ids.stream().map(authors::get).toList();
}
/** How many times authors were looked up, to see batching at work. */
public int authorLookups() {
return authorLookups.get();
}
/**
* Replaces a book with what {@code change} makes of it, atomically, and returns the new copy. An exception thrown
* by {@code change} leaves the book as it was.
*/
public Optional<Book> update(String id, UnaryOperator<Book> change) {
return Optional.ofNullable(books.computeIfPresent(id, (key, book) -> change.apply(book)));
}
}Bookshop.java starts the server and holds one resolver per operation:
package com.example.bookshop;
import com.sun.net.httpserver.HttpServer;
import dev.rayfold.core.Code;
import dev.rayfold.core.RayfoldException;
import dev.rayfold.core.RayfoldServer;
import dev.rayfold.java.CommandOutcome;
import dev.rayfold.java.Rayfold;
import dev.rayfold.java.Values;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.function.LongSupplier;
public final class Bookshop {
private Bookshop() {}
public static void main(String[] args) throws IOException {
start(4000, new Store());
System.out.println("Bookshop API at http://localhost:4000/rayfold, explorer at http://localhost:4000/rayfold/explorer");
}
public static HttpServer start(int port, Store store) throws IOException {
return Rayfold.http(server(store))
.viewer(Auth::viewerOf)
.explorer("Bookshop")
.start(port);
}
public static RayfoldServer server(Store store) {
return server(store, System::currentTimeMillis);
}
/** The same server telling the time by {@code now}, in epoch milliseconds: a test passes a clock it can move. */
public static RayfoldServer server(Store store, LongSupplier now) {
return Rayfold.server(schema())
.clock(now)
.query("book", (args, ctx) -> store.book(args.getString("id")).orElse(null))
.query("books", (args, ctx) -> page(store.books(), args.getValues("page")))
.command("buy", (args, ctx) -> {
String bookId = args.getString("bookId");
int qty = args.getInt("qty");
Book book = store.update(bookId, current -> {
if (qty > current.stock()) {
throw Rayfold.domainError("OutOfStock",
Map.of("bookId", current.id(), "available", current.stock()),
"Only " + current.stock() + " left of " + current.title());
}
return current.withStock(current.stock() - qty);
}).orElseThrow(() -> notFound(bookId));
return stockChanged(book);
})
.command("restock", (args, ctx) -> {
String bookId = args.getString("bookId");
int qty = args.getInt("qty");
Book book = store.update(bookId, current -> current.withStock(current.stock() + qty))
.orElseThrow(() -> notFound(bookId));
return stockChanged(book);
})
// called once per level with every book in the result, and answered with one lookup
.field("Book", "author", (books, args, ctx) ->
store.authors(books.stream().map(book -> book.getString("authorId")).toList()))
.build();
}
/** The schema's built-in {@code Page<Book>}. */
private record BookPage(List<Book> items, String cursor, boolean hasMore, int total) {}
/** One page of books sorted by id, so a cursor is the id of the last book on the page before. */
private static BookPage page(List<Book> books, Values page) {
int first = page.getInt("first");
String after = page.getString("after");
int start = after != null
? (int) books.stream().filter(book -> book.id().compareTo(after) <= 0).count()
: Objects.requireNonNullElse(page.getInt("offset"), 0);
List<Book> items = books.stream().skip(start).limit(first).toList();
String cursor = items.isEmpty() ? null : items.getLast().id();
return new BookPage(items, cursor, start + items.size() < books.size(), books.size());
}
/** The changed book as the command's result, plus the event the schema says the command emits. */
private static CommandOutcome stockChanged(Book book) {
return Rayfold.result(book).emit("StockChanged", Map.of("bookId", book.id(), "stock", book.stock()));
}
private static RayfoldException notFound(String bookId) {
return Rayfold.error(Code.NOT_FOUND, "No book " + bookId);
}
private static String schema() {
try (InputStream in = Bookshop.class.getResourceAsStream("/bookshop.rayfold")) {
Objects.requireNonNull(in, "bookshop.rayfold is missing from the classpath");
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
}Rayfold.server(schema)takes one function per operation, named as in the schema. Arguments arrive already checked against it,@rangeincluded.- Return plain records such as
Book; the runtime turns them into the shape the client asked for. - The
authorfield loader receives every book in the result at once and returns their authors in the same order, so a page of 50 books looks up authors once. Rayfold.domainError("OutOfStock", ...)is the error the schema declared. Clients receive it by name, with its data.Rayfold.result(book).emit(...)returns the changed book and the event the schema says the command emits. The book becomes a patch for every client cache.
4. Say who is calling
Your identity provider signs the user in and gives the client an access token (a JWT). The server verifies it with Nimbus JOSE + JWT against the provider's published keys, and turns its claims into the viewer:
package com.example.bookshop;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.MACSigner;
import com.nimbusds.jose.jwk.source.ImmutableSecret;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import com.sun.net.httpserver.HttpExchange;
import dev.rayfold.core.Code;
import dev.rayfold.java.Rayfold;
import java.net.MalformedURLException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** Who is calling: the bearer token the identity provider issued at sign-in, verified before anything in it is believed. */
public final class Auth {
private Auth() {}
/** Signs development tokens when no identity provider is configured. Never set in production. */
private static final byte[] DEV_SECRET = "bookshop development key, not a secret".getBytes(StandardCharsets.UTF_8);
private static final String DEV_ISSUER = "http://localhost:4000/dev";
private static final Pattern BEARER = Pattern.compile("^Bearer (\\S+)$");
// In production, AUTH_JWKS_URL and AUTH_ISSUER name your identity provider (Auth0, Entra ID, Keycloak, Cognito, ...).
// Nimbus fetches its published signing keys, caches them and follows its key rotation.
private static final DefaultJWTProcessor<SecurityContext> TOKENS = tokens(System.getenv("AUTH_JWKS_URL"), System.getenv("AUTH_ISSUER"));
private static DefaultJWTProcessor<SecurityContext> tokens(String jwks, String issuer) {
if (jwks != null && issuer == null) throw new IllegalStateException("AUTH_ISSUER must be set with AUTH_JWKS_URL");
var tokens = new DefaultJWTProcessor<SecurityContext>();
try {
tokens.setJWSKeySelector(jwks != null
? new JWSVerificationKeySelector<>(JWSAlgorithm.RS256, JWKSourceBuilder.<SecurityContext>create(URI.create(jwks).toURL()).build())
: new JWSVerificationKeySelector<>(JWSAlgorithm.HS256, new ImmutableSecret<>(DEV_SECRET)));
} catch (MalformedURLException e) {
throw new IllegalArgumentException("AUTH_JWKS_URL is not a URL: " + jwks, e);
}
// the audience, the issuer, a subject and an expiry are required; the expiry is checked against the clock
tokens.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(Set.of("bookshop"),
new JWTClaimsSet.Builder().issuer(jwks != null ? issuer : DEV_ISSUER).build(), Set.of("sub", "exp"), null));
return tokens;
}
/** The viewer the schema's policies see as {@code viewer}, or null for an anonymous request. */
static Map<String, String> viewerOf(HttpExchange exchange) {
String authorization = exchange.getRequestHeaders().getFirst("Authorization");
if (authorization == null) return null;
Matcher bearer = BEARER.matcher(authorization);
if (!bearer.matches()) throw Rayfold.error(Code.UNAUTHENTICATED, "Expected Authorization: Bearer <token>");
JWTClaimsSet claims;
try {
claims = TOKENS.process(bearer.group(1), null); // checks the signature and the claims above; nothing is read before both pass
} catch (Exception e) {
throw Rayfold.error(Code.UNAUTHENTICATED, "Invalid or expired token");
}
// `role` is the claim this provider carries roles in; the schema's rules compare against what it maps to
return Map.of("id", claims.getSubject(), "role", "staff".equals(claims.getClaim("role")) ? "staff" : "customer");
}
/** A token as the identity provider would issue it, signed with the development key: for local runs and tests. */
public static String devToken(String subject, String role) {
var now = new Date();
var claims = new JWTClaimsSet.Builder().subject(subject).issuer(DEV_ISSUER).audience("bookshop").claim("role", role)
.issueTime(now).expirationTime(new Date(now.getTime() + 8 * 3_600_000L)).build();
try {
var jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claims);
jwt.sign(new MACSigner(DEV_SECRET));
return jwt.serialize();
} catch (JOSEException e) {
throw new IllegalStateException(e);
}
}
/** Prints a development token: {@code ./mvnw -q compile exec:java -Dexec.mainClass=com.example.bookshop.Auth -Dexec.args=staff} */
public static void main(String[] args) {
boolean staff = args.length > 0 && args[0].equals("staff");
System.out.println(devToken(staff ? "s1" : "u1", staff ? "staff" : "customer"));
}
}What viewerOf returns is viewer in the schema's @allow rules. The resolvers never check permissions themselves. Until you have a provider, the server signs and checks tokens with the development key at the top of the class. devToken makes a token as a provider would, and main prints one.
5. Start the server
main and start, at the top of Bookshop.java, serve the endpoint on port 4000 with the explorer beside it:
./mvnw compile exec:javaOpen http://localhost:4000/rayfold/explorer to browse the operations and send requests. To try the commands, paste a development token into the auth field. This command prints a customer's token, and with -Dexec.args=staff it prints a staff member's:
./mvnw -q compile exec:java -Dexec.mainClass=com.example.bookshop.AuthOr call it with curl:
TOKEN=$(./mvnw -q compile exec:java -Dexec.mainClass=com.example.bookshop.Auth)
curl -s localhost:4000/rayfold -H 'content-type: application/rayfold+json' -H "authorization: Bearer $TOKEN" \
-d '{"rayfold":"0.1","ops":[{"id":1,"op":"buy","args":{"bookId":"b1"},"key":"first-purchase-0001"}]}'6. Call it
Any Rayfold client works with this server: the TypeScript client, the React hooks, or the Kotlin client, which Java code can use too.
Next
- How shapes, loaders and pages work: Queries and shapes.
- Rules in the schema: Who can do what.
- Generated records and the rest of the Java API: the Java and Spring Boot guide.