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, for example from start.spring.io, and add the Rayfold starter. The OAuth2 resource server starter is only there for the demo tokens in step 4:

xml
<dependencies>
  <dependency>
    <groupId>dev.rayfold</groupId>
    <artifactId>rayfold-spring-boot-starter</artifactId>
    <version>0.1.0</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
  </dependency>

  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

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:

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

3. Write the resolvers

java
@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);
}
  • @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.

Related data is loaded in batches:

java
// 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());
}

@RayfoldField 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

The starter takes the viewer from Spring Security: the signed-in principal's name becomes viewer.id and its role becomes viewer.role, which the schema's @allow rules check. Here two fixed tokens stand in for a real authorization server:

java
@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain security(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(requests -> requests.anyRequest().permitAll())
            .oauth2ResourceServer(oauth2 -> oauth2.opaqueToken(Customizer.withDefaults()))
            // Rayfold checks the Origin and content type of every request that can change data itself
            .csrf(csrf -> csrf.ignoringRequestMatchers("/rayfold/**"))
            .build();
    }

    // Two fixed tokens stand in for real authentication; a real application asks its authorization server here.
    // The starter turns the signed-in principal into the viewer: its name is viewer.id and its first role viewer.role.
    @Bean
    OpaqueTokenIntrospector tokens() {
        Map<String, OAuth2AuthenticatedPrincipal> principals = Map.of(
            "customer", principal("u1", "customer"),
            "staff", principal("s1", "staff"));
        return token -> Optional.ofNullable(principals.get(token))
            .orElseThrow(() -> new BadOpaqueTokenException("Unknown token"));
    }

    private static OAuth2AuthenticatedPrincipal principal(String id, String role) {
        return new DefaultOAuth2AuthenticatedPrincipal(id, Map.of("sub", id), AuthorityUtils.createAuthorityList("ROLE_" + role));
    }
}

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. Paste customer or staff as the token to try the commands. Or call it with curl:

sh
curl -s localhost:4000/rayfold -H 'content-type: application/rayfold+json' -H 'authorization: Bearer staff' \
  -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.