Skip to content

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
<?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:

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:

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);
    }
}
java
package com.example.bookshop;

public record Author(String id, String name) {}
java
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:

java
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, @range included.
  • Return plain records such as Book; the runtime turns them into the shape the client asked for.
  • The author field 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:

java
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:

sh
./mvnw compile exec:java

Open 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:

sh
./mvnw -q compile exec:java -Dexec.mainClass=com.example.bookshop.Auth

Or call it with curl:

sh
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 ​

Released under the Apache-2.0 license.