Stage 28: return the same products as JSON

Follow one familiar product request and observe the framework step introduced in this lesson.

Open this stage in the online learning path

Before you start

Use JDK 21 or newer and Maven 3.9 or newer. Verify with java -version and mvn -version. Extract this project into its own folder; do not merge its files with another stage.

This lesson reuses the product catalog and Todo responsibilities from Stages 8–14. Only the feature described below is new. Read the ordered explanation before opening the complete files.

Browser → Tomcat → DispatcherServlet → HandlerMapping / adapter → Controller → View / JSON converter → Response

What we change and why

  1. Reuse a tiny product catalog

    ProductService returns two Product records. There is no database in this lesson, so the request-handling mechanism is the only changing concern.

  2. Follow the HTML request

    Tomcat receives /products and invokes the registered DispatcherServlet. A HandlerMapping identifies ProductController.products. A HandlerAdapter invokes that method. The controller adds products to Model and returns the view name products.

  3. Resolve and render the view

    Spring MVC’s view-resolution infrastructure locates the Thymeleaf view. The Thymeleaf template resolver finds templates/products.html, and the template engine renders its rows. Returning products is a view name, not the returned product data.

  4. See framework infrastructure without implementing it

    Boot configures the DispatcherServlet and MVC infrastructure because this application has the MVC starter. You write the controller, service and template. Use a debugger breakpoint in ProductController.products, then step through the call stack to see the framework invoking it.

  5. Compare JSON output

    Open /api/products or send GET in Postman. @RestController applies response-body semantics. A suitable HttpMessageConverter serializes the returned List<Product> to JSON; it does not select products.html. Both endpoints use the same service. The same annotation does not mean every value uses JSON: converter selection also considers the returned type and negotiated media type.

Run this example

Open a terminal in the extracted folder containing pom.xml. Run one command at a time.

Terminal · Windows, macOS and Linux
mvn test
mvn spring-boot:run

Open http://localhost:8128/products. Keep the terminal running. Stop with Ctrl+C. If the port is occupied, edit server.port in application.properties before restarting.

Expected result

Expected output · ignore framework startup messages
Browser: a table with Notebook / 80 and Pen / 20
Terminal: Controller: products
GET /api/products: [{"id":1,"name":"Notebook","price":80},{"id":2,"name":"Pen","price":20}]
HTML Content-Type: text/html
API Content-Type: application/json

Object identifiers and elapsed times can vary. Compare the relationship between values rather than copying an identifier.

Understand each complete file

Create the following files at exactly these paths, or use the ZIP. Source files include their imports. The test checks the behaviour described in the lesson.

pom.xml
pom.xml · complete file
<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.edupoly</groupId><artifactId>spring-stage-28-json-converter</artifactId><version>1.0.0</version>
  <properties><java.version>21</java.version></properties>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency>
  </dependencies>
  <build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
src/main/java/com/edupoly/lesson/Product.java
src/main/java/com/edupoly/lesson/Product.java · complete file
package com.edupoly.lesson;

public record Product(int id, String name, int price) { }
src/main/java/com/edupoly/lesson/ProductService.java
src/main/java/com/edupoly/lesson/ProductService.java · complete file
package com.edupoly.lesson;

import java.util.List;
import org.springframework.stereotype.Service;
@Service public class ProductService {
    public List<Product> findAll() { return List.of(new Product(1, "Notebook", 80), new Product(2, "Pen", 20)); }
}
src/main/java/com/edupoly/lesson/ProductController.java
src/main/java/com/edupoly/lesson/ProductController.java · complete file
package com.edupoly.lesson;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller public class ProductController {
    private final ProductService service;
    public ProductController(ProductService service) { this.service = service; }
    @GetMapping("/products") public String products(Model model) {
        System.out.println("Controller: products");
        model.addAttribute("products", service.findAll()); return "products";
    }
}
src/main/java/com/edupoly/lesson/ProductApi.java
src/main/java/com/edupoly/lesson/ProductApi.java · complete file
package com.edupoly.lesson;

import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController public class ProductApi {
    private final ProductService service;
    public ProductApi(ProductService service) { this.service = service; }
    @GetMapping("/api/products") public List<Product> products() { return service.findAll(); }
}
src/main/resources/templates/products.html
src/main/resources/templates/products.html · complete file
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"><head><meta charset="utf-8"><title>Products</title></head>
<body><h1>Products</h1><table><thead><tr><th>ID</th><th>Name</th><th>Price</th></tr></thead>
<tbody><tr th:each="product : ${products}"><td th:text="${product.id}"></td><td th:text="${product.name}"></td><td th:text="${product.price}"></td></tr></tbody></table></body></html>
src/main/java/com/edupoly/lesson/Application.java
src/main/java/com/edupoly/lesson/Application.java · complete file
package com.edupoly.lesson;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
    public static void main(String[] args) { SpringApplication.run(Application.class, args); }
}
src/main/resources/application.properties
src/main/resources/application.properties · complete file
server.port=8128
spring.application.name=spring-stage-28-json-converter
src/test/java/com/edupoly/lesson/LessonTest.java
src/test/java/com/edupoly/lesson/LessonTest.java · complete file
package com.edupoly.lesson;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

class LessonTest {
    @Test void realHttpRequestRendersProducts() throws Exception {
        try (var context = org.springframework.boot.SpringApplication.run(Application.class, "--server.port=0")) {
            int port = context.getEnvironment().getProperty("local.server.port", Integer.class);
            var client = java.net.http.HttpClient.newHttpClient();
            String base = "http://127.0.0.1:" + port;
            var response = client.send(java.net.http.HttpRequest.newBuilder(java.net.URI.create(base + "/products")).build(), java.net.http.HttpResponse.BodyHandlers.ofString());
            assertEquals(200, response.statusCode()); assertTrue(response.body().contains("Notebook"));
            assertFalse(response.body().contains("th:each"));
            assertTrue(response.headers().firstValue("Content-Type").orElse("").contains("text/html"));
            var api = client.send(java.net.http.HttpRequest.newBuilder(java.net.URI.create(base + "/api/products")).build(), java.net.http.HttpResponse.BodyHandlers.ofString());
            assertEquals(200, api.statusCode()); assertTrue(api.body().contains("\"Notebook\""));
            assertTrue(api.headers().firstValue("Content-Type").orElse("").contains("application/json"));
            var missing = client.send(java.net.http.HttpRequest.newBuilder(java.net.URI.create(base + "/missing")).build(), java.net.http.HttpResponse.BodyHandlers.ofString());
            assertEquals(404, missing.statusCode());
        }
    }
}

Try a failure, then correct it

Return a plain view name from a @RestController method and observe that the text becomes response data. Use @Controller for a view-producing handler.

Words you can now explain

WordMeaning in this example
Message converterWrites returned Product objects as an HTTP response body.
Response bodyThe actual JSON data returned instead of a template name.

Practice assignment

Add one product and verify both the rendered table and, where present, JSON output. Explain the part of the request flow changed in this stage.

Read the official documentation →