Spring REST HTTP Server Tutorial

This tutorial covers the YAML endpoint model in depth: naming conventions, multi-method service classes, multiple path variables, and custom response headers. It assumes you have already completed the Getting Started tutorial.

The tutorial exposes these endpoints:

  • GET /greeting returns {"message":"Hello, World!"} using a Spring bean
  • GET /greeting/{name} returns a personalised greeting using a path parameter
  • GET /greeting/formal/{name} and GET /greeting/casual/{name} served by two methods of the same class, illustrating the method: key
  • GET /greeting/entity/{name} returns custom response headers via ResponseEntity
  • GET /greeting/{style}/{name} demonstrates two path variables in one endpoint

Tutorial Source

Maven dependency

The starter is published to Maven Central, so no additional repository configuration is needed. Adding a single dependency to pom.xml is all that is required:

		<dependency>
			<groupId>net.officefloor.springboot</groupId>
			<artifactId>officefloor-rest-spring-boot-4-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.

Application class

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);
	}
}

YAML endpoint files — path and method from the file name

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/
    ├── {name}.GET.yml           →  GET  /greeting/{name}
    ├── entity/
    │   └── {name}.GET.yml       →  GET  /greeting/entity/{name}
    ├── formal/
    │   └── {name}.GET.yml       →  GET  /greeting/formal/{name}
    ├── casual/
    │   └── {name}.GET.yml       →  GET  /greeting/casual/{name}
    └── {style}/
        └── {name}.GET.yml       →  GET  /greeting/{style}/{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 /.

YAML endpoint files — entries are named steps

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.

Multi-method classes — method: is required

When a class has more than one public method, OfficeFloor cannot determine which to call and the application fails to start:

Require configuring method for service (GreetingStyleLogic) as it contains
multiple public methods (casual, formal)

Every YAML entry that references such a class must include method: to name which method to invoke.

GreetingStyleLogic is a single class with two independent greeting styles, each serving its own endpoint:

public class GreetingStyleLogic {

	public void formal(@PathVariable(name = "name") String name,
			ObjectResponse<GreetingResponse> response) {
		response.send(new GreetingResponse("Good day, " + name + "."));
	}

	public void casual(@PathVariable(name = "name") String name,
			ObjectResponse<GreetingResponse> response) {
		response.send(new GreetingResponse("Hey, " + name + "!"));
	}
}

The formal endpoint YAML specifies method: formal:

service:
  class: net.officefloor.tutorial.springresthttpserver.GreetingStyleLogic
  method: formal

The casual endpoint specifies method: casual:

service:
  class: net.officefloor.tutorial.springresthttpserver.GreetingStyleLogic
  method: casual

Both entries reference the same class but each picks a different method. The rule holds regardless of how many entries reference the class or whether those entries appear in one YAML file or several.

Spring bean injection — parameters injected by type

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.

ObjectResponse with ResponseEntity — Spring-compatible headers and status

For full Spring compatibility, ObjectResponse also accepts a ResponseEntity<T> as its type parameter. This lets you set custom response headers or a non-200 status code while keeping the same dependency-injected style:

public class GetGreetingEntityLogic {

	public void service(
			@PathVariable(name = "name") String name,
			GreetingService greetingService,
			ObjectResponse<ResponseEntity<GreetingResponse>> response) {
		response.send(ResponseEntity.ok()
				.header("X-Greeting-Name", name)
				.body(new GreetingResponse(greetingService.greet(name))));
	}
}

Using ObjectResponse<ResponseEntity<GreetingResponse>> gives full control over the HTTP response while staying consistent with the rest of the OfficeFloor programming model. When you only need the body and a 200 status, ObjectResponse<T> is simpler; reach for the ResponseEntity form when you need headers or a specific status code.

Spring annotations on parameters

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.

Note: always use the name = attribute form — @PathVariable(name = "name") and @RequestParam(name = "name"). The shorthand @PathVariable("name") sets the value attribute, which requires @AliasFor annotation synthesis to alias to name; OfficeFloor resolves arguments from raw Java reflection where that synthesis is not applied, so the shorthand silently produces an empty name and the binding fails.

Multiple path variables — nested resource URLs

A URL path with two variable segments — such as GET /greeting/{style}/{name} — is declared by nesting the YAML file inside a variable-named directory:

officefloor/rest/
└── greeting/
    └── {style}/
        └── {name}.GET.yml     →  GET  /greeting/{style}/{name}

Both variables are available in the service method. Use the name = attribute form on @PathVariable — the shorthand @PathVariable("style") silently fails in OfficeFloor because raw Java reflection does not apply the @AliasFor alias:

service:
  class: net.officefloor.tutorial.springresthttpserver.GetStyledGreetingLogic
public class GetStyledGreetingLogic {

	public void service(
			@PathVariable(name = "style") String style,
			@PathVariable(name = "name") String name,
			GreetingService greetingService,
			ObjectResponse<GreetingResponse> response) {
		response.send(new GreetingResponse(style + ": " + greetingService.greet(name)));
	}
}

Literal path segments always take priority over variable segments during routing. The test below confirms that GET /greeting/royal/Alice resolves the two-variable endpoint while GET /greeting/formal/Alice still routes to the dedicated greeting/formal/{name}.GET.yml endpoint:

// routes to greeting/{style}/{name}.GET.yml  → "royal: Hello, Alice!"
GET /greeting/royal/Alice

// routes to greeting/formal/{name}.GET.yml   → "Good day, Alice."  (literal wins)
GET /greeting/formal/Alice

Testing

@SpringBootTest
@AutoConfigureMockMvc
public class SpringRestHttpServerTest {

	@Autowired
	private MockMvc mvc;

	@Autowired
	private ObjectMapper mapper;

	@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 getFormalGreeting() throws Exception {
		mvc.perform(MockMvcRequestBuilders.get("/greeting/formal/Alice")
				.accept(MediaType.APPLICATION_JSON))
				.andExpect(status().isOk())
				.andExpect(content().json(mapper.writeValueAsString(new GreetingResponse("Good day, Alice."))));
	}

	@Test
	public void getCasualGreeting() throws Exception {
		mvc.perform(MockMvcRequestBuilders.get("/greeting/casual/Alice")
				.accept(MediaType.APPLICATION_JSON))
				.andExpect(status().isOk())
				.andExpect(content().json(mapper.writeValueAsString(new GreetingResponse("Hey, Alice!"))));
	}

	@Test
	public void getGreetingEntity() throws Exception {
		mvc.perform(MockMvcRequestBuilders.get("/greeting/entity/OfficeFloor")
				.accept(MediaType.APPLICATION_JSON))
				.andExpect(status().isOk())
				.andExpect(header().string("X-Greeting-Name", "OfficeFloor"))
				.andExpect(content().json(mapper.writeValueAsString(new GreetingResponse("Hello, OfficeFloor!"))));
	}

	@Test
	public void getStyledGreeting_bothVariablesExtracted() throws Exception {
		mvc.perform(MockMvcRequestBuilders.get("/greeting/royal/Alice")
				.accept(MediaType.APPLICATION_JSON))
				.andExpect(status().isOk())
				.andExpect(content().json(mapper.writeValueAsString(
						new GreetingResponse("royal: Hello, Alice!"))));
	}

	@Test
	public void getStyledGreeting_literalSegmentWinsOverVariable() throws Exception {
		// /greeting/formal/Alice matches greeting/formal/{name}.GET.yml (literal "formal"),
		// NOT greeting/{style}/{name}.GET.yml — literal segments take priority over variables
		mvc.perform(MockMvcRequestBuilders.get("/greeting/formal/Alice")
				.accept(MediaType.APPLICATION_JSON))
				.andExpect(status().isOk())
				.andExpect(content().json(mapper.writeValueAsString(
						new GreetingResponse("Good day, Alice."))));
	}

}

Running against a real server

Because the application is standard Spring Boot, it can be run directly without any test harness:

mvn spring-boot:run

With the server running, the endpoints respond to plain HTTP:

curl http://localhost:8080/greeting
{"message":"Hello, World!"}

curl http://localhost:8080/greeting/OfficeFloor
{"message":"Hello, OfficeFloor!"}

The integration test does the same thing programmatically. @SpringBootTest(webEnvironment = RANDOM_PORT) starts an embedded server on a random port and TestRestTemplate makes real HTTP calls — no MockMvc involved:

@AutoConfigureTestRestTemplate
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class SpringRestHttpServerRealServerTest {

	@Autowired
	private TestRestTemplate restTemplate;

	@Test
	public void getGreeting() {
		ResponseEntity<GreetingResponse> response = restTemplate.getForEntity(
				"/greeting", GreetingResponse.class);
		assertEquals(HttpStatus.OK, response.getStatusCode());
		assertEquals(new GreetingResponse("Hello, World!"), response.getBody());
	}

	@Test
	public void getNamedGreeting() {
		ResponseEntity<GreetingResponse> response = restTemplate.getForEntity(
				"/greeting/OfficeFloor", GreetingResponse.class);
		assertEquals(HttpStatus.OK, response.getStatusCode());
		assertEquals(new GreetingResponse("Hello, OfficeFloor!"), response.getBody());
	}

	@Test
	public void getFormalGreeting() {
		ResponseEntity<GreetingResponse> response = restTemplate.getForEntity(
				"/greeting/formal/Alice", GreetingResponse.class);
		assertEquals(HttpStatus.OK, response.getStatusCode());
		assertEquals(new GreetingResponse("Good day, Alice."), response.getBody());
	}

	@Test
	public void getCasualGreeting() {
		ResponseEntity<GreetingResponse> response = restTemplate.getForEntity(
				"/greeting/casual/Alice", GreetingResponse.class);
		assertEquals(HttpStatus.OK, response.getStatusCode());
		assertEquals(new GreetingResponse("Hey, Alice!"), response.getBody());
	}

	@Test
	public void getGreetingEntity() {
		ResponseEntity<GreetingResponse> response = restTemplate.getForEntity(
				"/greeting/entity/OfficeFloor", GreetingResponse.class);
		assertEquals(HttpStatus.OK, response.getStatusCode());
		assertEquals("OfficeFloor", response.getHeaders().getFirst("X-Greeting-Name"));
		assertEquals(new GreetingResponse("Hello, OfficeFloor!"), response.getBody());
	}
}

Next

The Spring Boot 3 tutorial shows how to add OfficeFloor REST to an existing Spring Boot 3 application and explains which version-specific starter to choose.