Skip to content

Java and Spring Boot ​

ArtifactFor
dev.rayfold:rayfold-javaA Rayfold server with a Java API: a builder, functional resolver interfaces, records and maps as results.
dev.rayfold:rayfold-spring-boot-starterThe same inside Spring Boot 4: annotated methods on your beans, Spring MVC, Spring Security.
dev.rayfold:rayfold-testUnit tests for either without a network: a blocking caller per viewer, with maps in and plain values out. Test scope.

Both servers read .rayfold schema files directly. Java 21 or later.

Since 0.2.2

rayfold-test, .clock(...), ctx.now() and the starter's use of a Clock bean came with 0.2.2.

Plain Java ​

xml
<dependency>
  <groupId>dev.rayfold</groupId>
  <artifactId>rayfold-java</artifactId>
  <version>0.2.3</version>
</dependency>
java
import dev.rayfold.java.Rayfold;

record Book(String id, String title, int stock, String authorId) {}
record Author(String id, String name) {}

RayfoldServer server = Rayfold.server(Files.readString(Path.of("schema.rayfold")))
    .query("book", (args, ctx) -> books.get(args.getString("id")))
    .field("Book", "author", (parents, args, ctx) ->
        parents.stream().map(p -> authors.get(p.getString("authorId"))).toList())   // one call for every book
    .command("buy", (args, ctx) -> {
        Book b = books.get(args.getString("id"));
        int qty = args.getInt("qty");
        if (qty > b.stock()) throw Rayfold.domainError("OutOfStock", Map.of("available", b.stock()), "Only " + b.stock() + " left");
        Book next = new Book(b.id(), b.title(), b.stock() - qty, b.authorId());
        books.put(b.id(), next);
        return Rayfold.result(next).emit("Sold", Map.of("bookId", b.id(), "qty", qty));
    })
    .build();

HttpServer http = Rayfold.http(server)
    .allowedOrigins("https://app.example")
    .viewer(exchange -> userOf(exchange))          // a Map or record with "id", or null when anonymous
    .start(8080, "/rayfold", "0.0.0.0");            // start(8080) alone listens on loopback only
  • Results can be records, maps, lists, enums, Optional, java.time values or objects with getters. BigDecimal travels as exact text, which is how the schema's Decimal is sent.
  • args.getString, getInt, getLong, getDouble, getBoolean, getDecimal, getValues (nested input) and getList read arguments. ctx.viewerId() is the signed-in user.
  • queryAsync, commandAsync and fieldAsync take resolvers that return a CompletionStage.
  • Registering a resolver for an operation or field the schema does not have fails when you call it, not at the first request.
  • .clock(clock) sets the clock the server tells the time by: a java.time.Clock, or epoch milliseconds from a LongSupplier. now() in a policy and ctx.now() in a resolver read it, and the default idempotency store's records expire by it (a store given to .idempotencyStore(...) keeps its own clock). Without one, the server uses the system clock (Testing).

Spring Boot ​

xml
<dependency>
  <groupId>dev.rayfold</groupId>
  <artifactId>rayfold-spring-boot-starter</artifactId>
  <version>0.2.3</version>
</dependency>

Put the schema at src/main/resources/schema.rayfold and annotate methods on any bean:

java
@Component
public class BookResolvers {
    @RayfoldQuery("book")
    public Book book(@Arg String id) { return books.get(id); }

    @RayfoldField(type = "Book", field = "author")
    public List<Author> author(List<Book> parents) {   // called once per level, for every book
        return parents.stream().map(b -> authors.get(b.authorId())).toList();
    }

    @RayfoldCommand("buy")
    public Book buy(@Arg String id, @Arg int qty, Context ctx) { ... }

    @RayfoldStream("ticks")
    public Stream<Integer> ticks(@Arg int n) { return IntStream.range(0, n).boxed(); }
}

The endpoint is at /rayfold. Arguments and results go through your application's Jackson mapper, so records, java.time types and Jackson annotations behave as in Spring MVC. Methods may return a CompletableFuture. A parameter can be @Arg (named after the parameter), Values for all arguments, Context, or the runtime's own RayfoldContext.

Startup fails with a clear message when a method names an operation, field or argument the schema does not have, or has a parameter nothing can fill.

Settings ​

properties
rayfold.schema=classpath:schema.rayfold
rayfold.path=/rayfold
rayfold.allowed-origins=https://app.example
rayfold.manifest=redacted
rayfold.trusted-shapes=false
rayfold.budget=1000
rayfold.websocket=true
rayfold.allowed-hosts=api.example.com
rayfold.max-body-bytes=1048576
rayfold.max-depth=8
rayfold.explorer.enabled=false
rayfold.explorer.title=Bookshop

rayfold.explorer.enabled=true serves the explorer at {path}/explorer; it is off by default.

A browser on an origin in rayfold.allowed-origins gets its CORS preflight answered with 204 and the Access-Control-Allow-* headers, as do the responses that follow; no other origin gets them.

Live queries and WebSocket ​

Live queries work over the HTTP endpoint as they are: the response stays open and gets keep-alives while nothing changes. For many batches over one connection, add spring-boot-starter-websocket. The starter then serves the WebSocket transport at /rayfold/ws on the application's own port, with the same viewer (from Spring Security) and the same Origin check. rayfold.websocket=false turns it off.

Tracing ​

Declare an Instrumentation bean, such as new RayfoldOpenTelemetry(openTelemetry) from rayfold-opentelemetry, and every batch, op and loader call becomes a span (Tracing).

Time and tests ​

When the application declares a java.time.Clock bean, the server tells the time by it, so a test that replaces the bean moves the server's time too. Without one, or with several and none of them primary, it is the system clock.

A test can autowire the RayfoldServer the starter built and call it with rayfold-test, as any viewer and with no port open (Testing).

More than one instance ​

Idempotency records are kept in memory, so a command retried against another instance runs a second time. Declare an IdempotencyStore bean and the starter hands it to the server. JdbcIdempotencyStore from rayfold-jdbc keeps the records in the database. Every instance shares them, so a keyed command runs once across the fleet (JDBC).

java
@Bean
IdempotencyStore idempotency(DataSource dataSource) {
    // the store takes a Kotlin () -> Connection, which cannot throw SQLException from Java
    return new JdbcIdempotencyStore(() -> {
        try { return dataSource.getConnection(); } catch (SQLException e) { throw new IllegalStateException(e); }
    });
}

Declare a Relay bean as well and live queries and streams on every instance hear the commands run on the others. PgRelay from rayfold-jdbc carries them over Postgres LISTEN/NOTIFY (JDBC).

If you serve uploads, the store has to be shared for the same reason: an UploadStore bean turns the route on, and JdbcUploadStore puts the bytes where every instance can find them (Uploads). Without a bean there is no upload route at all.

Who is asking ​

With Spring Security on the classpath, the viewer is the signed-in user: id is the user name, roles the ROLE_ authorities without the prefix, role the first of them, and authorities all of them. Policies in the schema (@allow(read: viewer.role == "ADMIN")) see exactly that. Define a RayfoldViewerResolver bean to build the viewer yourself.

Rayfold protects its endpoint against cross-site requests on its own. It accepts only JSON and its binary format as bodies, which a browser cannot send cross-site without a preflight. It also checks the Origin of every request that can change data. Turn off Spring Security's CSRF token check for that path. The endpoint is open to anonymous callers, because the schema's @allow rules decide what a caller with no viewer may do. The rest of the application keeps its own rules:

java
@Bean
SecurityFilterChain security(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(a -> a.requestMatchers("/rayfold/**").permitAll().anyRequest().authenticated())
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
        .csrf(c -> c.ignoringRequestMatchers("/rayfold/**"))
        .build();
}

The Spring Boot guide shows the whole configuration, with roles read from the token.

Generated records ​

sh
npx @rayfold/cli gen java schema.rayfold --package com.example.api --class Api --out src/main/java/com/example/api/Api.java

One file with a record per entity, object, input, error and event, an enum per enum, Page and PageArgs, and Api.Ops with the operation names and an argument record per operation. Records of union members carry $type.

Released under the Apache-2.0 license.