This tutorial demonstrates using the OfficeFloor REST Spring Boot Starter to declare REST endpoints as YAML configuration files while retaining the full power of Spring's dependency injection.
Rather than annotating controllers with @RestController and @RequestMapping, each endpoint is a YAML file whose path in the officefloor/rest/ directory mirrors the URL it serves. The YAML file names one or more plain Java classes whose methods handle the request. Multiple methods can be composed (wired together in sequence directly in the YAML) without either class knowing about the other.
The example used in this tutorial is three endpoints:
GET /greeting returns {"message":"Hello, World!"} using a Spring beanGET /greeting/{name} returns a personalised greeting using a path parameterPOST /greeting validates the request, conditionally routes via a custom flow interface to a build step, then audits. This is via three composed methods, each with its own concern and Spring bean dependenciesAdding a single starter dependency to pom.xml is all that is required:
<dependency> <groupId>net.officefloor.springboot</groupId> <artifactId>officefloor-rest-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>
The officefloor-rest-spring-boot-starter auto-configures OfficeFloor into the Spring MVC pipeline. On start-up it scans the classpath for YAML files under officefloor/rest/ and registers each one as a HandlerInterceptor for the corresponding HTTP method and URL path. No additional Java or XML configuration is needed.
The application entry point is a standard Spring Boot class:
@SpringBootApplication
public class SpringRestApplication {
public static void main(String[] args) {
SpringApplication.run(SpringRestApplication.class, args);
}
}
Endpoints are declared as YAML files placed under src/main/resources/officefloor/rest/. The file name encodes both the HTTP method and the URL path:
officefloor/rest/
├── greeting.GET.yml → GET /greeting
├── greeting.POST.yml → POST /greeting
└── greeting/
└── {name}.GET.yml → GET /greeting/{name}The naming convention is {path}.{METHOD}.yml. Directory structure below officefloor/rest/ becomes the URL path, so deeper URLs are produced simply by nesting files in sub-directories. Curly-brace segments such as {name} become URL path parameters. The special filename index.{METHOD}.yml maps to the root path /.
Inside each YAML file, entries are named steps. The label on each entry is a developer-chosen name used to wire steps together — it is not a keyword. A step entry identifies the Java class that implements it:
myLabel: class: com.example.MyLogic
The first entry in the file is always called when the HTTP request arrives.
When a class has only one public method that method is used automatically. When there are multiple public methods the method key selects the right one:
enrich: class: com.example.MyLogic method: enrich
The GreetingService is a plain Spring @Service:
@Service
public class GreetingService {
public String greet(String name) {
return "Hello, " + name + "!";
}
}
OfficeFloor registers every bean in the Spring application context as a managed object. Any parameter of a service method whose type matches a Spring bean is injected automatically — no annotation is needed on that parameter.
The handler for GET /greeting receives the bean and uses ObjectResponse to write the JSON response:
public class GetGreetingLogic {
public void service(GreetingService greetingService, ObjectResponse<GreetingResponse> response) {
response.send(new GreetingResponse(greetingService.greet("World")));
}
}
ObjectResponse<T> serialises the object to JSON and writes it to the HTTP response.
Service methods can use all the standard Spring MVC parameter annotations. The handler for GET /greeting/{name} uses @PathVariable:
public class GetNamedGreetingLogic {
public void service(
@PathVariable(name = "name") String name,
GreetingService greetingService,
ObjectResponse<GreetingResponse> response) {
response.send(new GreetingResponse(greetingService.greet(name)));
}
}
Both Spring annotations (@PathVariable, @RequestParam, @RequestHeader, @CookieValue, @RequestBody, @ModelAttribute, @RequestPart) and OfficeFloor's own annotations (@HttpPathParameter, @HttpQueryParameter, @HttpHeaderParameter, @HttpObject) are available on any service method parameter.
This is the full YAML for POST /greeting:
validate:
class: net.officefloor.tutorial.springresthttpserver.ValidateGreetingLogic
outputs:
valid: build
build:
class: net.officefloor.tutorial.springresthttpserver.PostGreetingLogic
next: audit
audit:
class: net.officefloor.tutorial.springresthttpserver.AuditGreetingLogic
Three steps, three classes, three distinct concerns. None of these classes imports or references the others — the YAML is the only place that knows about their order and wiring.
The validate step decides whether the request is usable. It declares a @Flow-annotated parameter whose type is a custom functional interface:
public class ValidateGreetingLogic {
@FunctionalInterface
public interface ValidGreetingFlow {
void flow(GreetingRequest request);
}
public void service(
@RequestBody GreetingRequest request,
@Flow("valid") ValidGreetingFlow valid,
ObjectResponse<GreetingResponse> response) {
if (request.getName() == null || request.getName().isBlank()) {
response.send(new GreetingResponse("Hello, World!"));
} else {
valid.flow(request);
}
}
}
OfficeFloor inspects ValidGreetingFlow.flow(GreetingRequest) at start-up and records that calling this interface passes a GreetingRequest as the parameter to the receiving step. Can use any single-abstract-method interface whose method has at most two parameters (an optional argument to target method followed by an optional FlowCallback).
The outputs map in the YAML connects the @Flow name to the receiving step:
validate:
class: ...ValidateGreetingLogic
outputs:
valid: buildWhen valid.flow(request) is called, OfficeFloor routes execution to the build step and makes request available as a @Parameter there. When the invalid branch fires instead (response.send(...)), the build and audit steps are never invoked for that request.
The build step receives the validated request as a @Parameter — the value that was passed to ValidGreetingFlow.flow(). It builds and returns a GreetingResponse:
public class PostGreetingLogic {
public GreetingResponse service(
@Parameter GreetingRequest request,
GreetingService greetingService) {
return new GreetingResponse(greetingService.greet(request.getName()));
}
}
nextWhen a step returns a value, OfficeFloor passes that value as a @Parameter to whichever step is named by next. Here, the GreetingResponse returned by build becomes the @Parameter received by audit:
public class AuditGreetingLogic {
public void service(
@Parameter GreetingResponse greeting,
AuditService auditService,
ObjectResponse<GreetingResponse> response) {
auditService.record(greeting.getMessage());
response.send(greeting);
}
}
The AuditService is a plain Spring bean:
@Service
public class AuditService {
private final List<String> entries = new ArrayList<>();
public void record(String message) {
entries.add(message);
}
public List<String> getEntries() {
return Collections.unmodifiableList(entries);
}
}
The request and response POJOs are:
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GreetingRequest {
private String name;
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public class GreetingResponse {
private String message;
}
The application is a standard Spring Boot application so tests use MockMvc. The postGreeting test injects AuditService from the application context to confirm that the audit step ran. The postGreetingWithBlankName test confirms the validate step short-circuited the chain:
@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestHttpServerTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper mapper;
@Autowired
private AuditService auditService;
@Test
public void getGreeting() throws Exception {
mvc.perform(MockMvcRequestBuilders.get("/greeting")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(mapper.writeValueAsString(new GreetingResponse("Hello, World!"))));
}
@Test
public void getNamedGreeting() throws Exception {
mvc.perform(MockMvcRequestBuilders.get("/greeting/OfficeFloor")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().json(mapper.writeValueAsString(new GreetingResponse("Hello, OfficeFloor!"))));
}
@Test
public void postGreeting() throws Exception {
mvc.perform(post("/greeting")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new GreetingRequest("Daniel"))))
.andExpect(status().isOk())
.andExpect(content().json(mapper.writeValueAsString(new GreetingResponse("Hello, Daniel!"))));
assertTrue(auditService.getEntries().contains("Hello, Daniel!"),
"Greeting should be recorded by AuditService");
}
@Test
public void postGreetingWithBlankName() throws Exception {
mvc.perform(post("/greeting")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(new GreetingRequest(""))))
.andExpect(status().isOk())
.andExpect(content().json(mapper.writeValueAsString(new GreetingResponse("Hello, World!"))));
}
}
The Function Injection tutorial explores function injection more deeply: a three-step pipeline with conditional branching, typed data passing between steps, and independent Spring service injection at each step.