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:
<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:
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:
rayfold.schema=classpath:bookshop.rayfold
rayfold.explorer.enabled=true
rayfold.explorer.title=Bookshop3. Write the resolvers
@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);
}@RayfoldQueryand@RayfoldCommandname the operation each method serves, and@Argbinds 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:
// 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:
@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
@SpringBootApplication
public class BookshopApplication {
public static void main(String[] args) {
SpringApplication.run(BookshopApplication.class, args);
}
}./mvnw spring-boot:runThe 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:
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
- How shapes, loaders and pages work: Queries and shapes.
- Rules in the schema: Who can do what.
- Configuration, generated records and tracing: the Java and Spring Boot guide.