Skip to content

Get started with Spring Boot ​

The bookshop as a Spring Boot application: resolvers are annotated methods on a bean, and Spring Security says who is calling. The finished project is examples/spring-boot. You need JDK 21.

1. Add the starter ​

Start from a Spring Boot 4 project (start.spring.io can make one). Add the Rayfold starter, Spring's OAuth2 resource server for the tokens in step 4, and rayfold-test for the unit tests:

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>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/>
  </parent>

  <groupId>com.example</groupId>
  <artifactId>bookshop</artifactId>
  <version>0.1.0-SNAPSHOT</version>

  <properties>
    <java.version>21</java.version>
  </properties>

  <dependencies>
    <dependency>
      <groupId>dev.rayfold</groupId>
      <artifactId>rayfold-spring-boot-starter</artifactId>
      <version>0.2.3</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
    </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.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>3.6.4</version>
        <configuration>
          <!-- ./mvnw -q compile exec:java -Dexec.args=staff prints a development token -->
          <mainClass>com.example.bookshop.DevTokens</mainClass>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

Since 0.2.2

rayfold-test came with 0.2.2. On 0.2.1, leave out its dependency.

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")

Point the starter at it in application.properties, and turn on the explorer. The identity lines are step 4's:

properties
spring.application.name=bookshop
server.port=4000

rayfold.schema=classpath:bookshop.rayfold
rayfold.explorer.enabled=true
rayfold.explorer.title=Bookshop

# Your identity provider (Auth0, Entra ID, Keycloak, Cognito, ...): Spring reads its signing keys from the issuer's
# discovery document and accepts only tokens that issuer signed for this audience. Left unset, the application signs
# and checks tokens with a development key instead (DevelopmentTokens in SecurityConfig.java).
#spring.security.oauth2.resourceserver.jwt.issuer-uri=https://your-tenant.example/
spring.security.oauth2.resourceserver.jwt.audiences=bookshop

3. Write the resolvers ​

The books are records, kept in memory by a Store bean:

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 org.springframework.stereotype.Component;

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. */
@Component
public 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)));
    }
}
java
package com.example.bookshop;

import dev.rayfold.core.Code;
import dev.rayfold.core.RayfoldException;
import dev.rayfold.java.CommandOutcome;
import dev.rayfold.java.Rayfold;
import dev.rayfold.spring.Arg;
import dev.rayfold.spring.RayfoldCommand;
import dev.rayfold.spring.RayfoldField;
import dev.rayfold.spring.RayfoldQuery;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.Map;
import java.util.Objects;

@Component
public class BookResolvers {
    /** The schema's built-in {@code PageArgs}, with its defaults already filled in. */
    public record PageArgs(int first, String after, Integer offset) {}

    /** The schema's built-in {@code Page<Book>}. */
    public record BookPage(List<Book> items, String cursor, boolean hasMore, int total) {}

    private final Store store;

    public BookResolvers(Store store) {
        this.store = store;
    }

    @RayfoldQuery("book")
    public Book book(@Arg String id) {
        return store.book(id).orElse(null);
    }

    @RayfoldQuery("books")
    public BookPage books(@Arg PageArgs page) {
        List<Book> books = store.books();
        // sorted by id, so a cursor is the id of the last book on the page before
        int start = page.after() != null
            ? (int) books.stream().filter(book -> book.id().compareTo(page.after()) <= 0).count()
            : Objects.requireNonNullElse(page.offset(), 0);
        List<Book> items = books.stream().skip(start).limit(page.first()).toList();
        String cursor = items.isEmpty() ? null : items.getLast().id();
        return new BookPage(items, cursor, start + items.size() < books.size(), books.size());
    }

    @RayfoldCommand("buy")
    public CommandOutcome buy(@Arg String bookId, @Arg int 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);
    }

    @RayfoldCommand("restock")
    public CommandOutcome restock(@Arg String bookId, @Arg int 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
    @RayfoldField(type = "Book", field = "author")
    public List<Author> author(List<Book> books) {
        return store.authors(books.stream().map(Book::authorId).toList());
    }

    /** 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);
    }
}
  • @RayfoldQuery and @RayfoldCommand name the operation each method serves, and @Arg binds its arguments, already checked against the schema.
  • Return plain records; the runtime turns them into the shape the client asked for.
  • Rayfold.domainError("OutOfStock", ...) is the error the schema declared. Clients receive it by name, with its data.
  • @RayfoldField loads related data in batches: it gets 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.

4. Say who is calling ​

Your identity provider signs the user in and gives the client an access token (a JWT). Spring Security verifies it: the signature against the provider's published keys, then the issuer, the audience and the expiry. The starter builds the viewer from the authenticated principal. Its name (the token's sub) becomes viewer.id, its roles become viewer.roles, and the first role becomes viewer.role. The schema's @allow rules check these.

java
package com.example.bookshop;

import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;

import java.util.List;

@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain security(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(requests -> requests.anyRequest().permitAll())
            // every bearer token is a JWT whose signature, issuer, audience and expiry Spring checks before the request
            // goes on; the issuer and its signing keys are configured in application.properties
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(roles())))
            // Rayfold checks the Origin and content type of every request that can change data itself
            .csrf(csrf -> csrf.ignoringRequestMatchers("/rayfold/**"))
            .build();
    }

    // The starter turns the signed-in principal into the viewer: its name, the token's `sub`, is viewer.id, and its
    // first role viewer.role. `role` is the claim this provider carries roles in.
    private static JwtAuthenticationConverter roles() {
        var authorities = new JwtGrantedAuthoritiesConverter();
        authorities.setAuthoritiesClaimName("role");
        authorities.setAuthorityPrefix("ROLE_");
        var converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(authorities);
        return converter;
    }
}

/**
 * With no identity provider configured, tokens are signed and checked with a development key (see {@link DevTokens}),
 * so the example runs on its own. Setting spring.security.oauth2.resourceserver.jwt.issuer-uri turns this off.
 */
@Configuration
@ConditionalOnExpression("'${spring.security.oauth2.resourceserver.jwt.issuer-uri:}' == ''")
class DevelopmentTokens {
    @Bean
    JwtDecoder developmentDecoder() {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(DevTokens.KEY).build();
        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
            JwtValidators.createDefaultWithIssuer(DevTokens.ISSUER),
            new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains("bookshop"))));
        return decoder;
    }
}

The provider is the issuer-uri in application.properties above. Until you set it, DevelopmentTokens signs and checks tokens with a development key instead, and DevTokens makes them as a provider would:

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.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;

import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Date;

/**
 * Tokens as an identity provider would issue them at sign-in, signed with a development key: for local runs and tests,
 * never production. {@code ./mvnw -q compile exec:java -Dexec.args=staff} prints one.
 */
public final class DevTokens {
    private DevTokens() {}

    static final SecretKey KEY = new SecretKeySpec("bookshop development key, not a secret".getBytes(StandardCharsets.UTF_8), "HmacSHA256");
    static final String ISSUER = "http://localhost:4000/dev";

    public static String token(String subject, String role) {
        var now = new Date();
        var claims = new JWTClaimsSet.Builder().subject(subject).issuer(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(KEY));
            return jwt.serialize();
        } catch (JOSEException e) {
            throw new IllegalStateException(e);
        }
    }

    public static void main(String[] args) {
        boolean staff = args.length > 0 && args[0].equals("staff");
        System.out.println(token(staff ? "s1" : "u1", staff ? "staff" : "customer"));
    }
}

5. Start the application ​

java
@SpringBootApplication
public class BookshopApplication {
    public static void main(String[] args) {
        SpringApplication.run(BookshopApplication.class, args);
    }
}
sh
./mvnw spring-boot:run

The endpoint is at http://localhost:4000/rayfold and the explorer at http://localhost:4000/rayfold/explorer. To try the commands, paste a development token into the auth field. This command prints a staff member's token; without -Dexec.args it prints a customer's:

sh
./mvnw -q compile exec:java -Dexec.args=staff

Or call it with curl:

sh
TOKEN=$(./mvnw -q compile exec:java -Dexec.args=staff)
curl -s localhost:4000/rayfold -H 'content-type: application/rayfold+json' -H "authorization: Bearer $TOKEN" \
  -d '{"rayfold":"0.1","ops":[{"id":1,"op":"book","args":{"id":"b1"},"shape":"{ title stock costPrice }"}]}'

6. Call it ​

Any Rayfold client works with this application: the TypeScript client, the React hooks, or the Kotlin client.

Next ​

Released under the Apache-2.0 license.