Skip to content
Educora
Advanced25 min17 / 18

A REST API with Spring Boot

Create a Spring Boot project, connect controller, service and repository layers with dependency injection, build a CRUD API and test it with curl.

Check yourself
In this lesson you will learn
  • Create and run a Spring Boot project
  • Explain the @RestController, @Service, @Repository layers and dependency injection
  • Write a CRUD API with correct HTTP statuses and test it with curl

A mobile app — say, an electronic school diary — gets grades from a server: the app sends an HTTP request (GET /api/students), and the server returns JSON. Such a server is called a REST API. In the Java world the most popular way to write one is Spring Boot: with a few annotations ordinary classes become a web server, and JSON, databases and configuration are wired up automatically.

Creating the project

  1. 1
    Open start.spring.io

    This is the official project generator (IntelliJ IDEA Ultimate has the same wizard). Choose Maven or Gradle for Project, Java for Language and 21 for Java.

  2. 2
    Fill in the metadata

    Group com.educora, Artifact school, Name SchoolApi. The package will be com.educora.school and the main class SchoolApiApplication.

  3. 3
    Add dependencies

    Press ADD DEPENDENCIES and choose Spring Web. Real projects usually also add Spring Data JPA, a database driver and Validation.

  4. 4
    Generate and open

    GENERATE downloads a ZIP archive. Unzip it, open the folder in your IDE and run the main class.

Java
package com.educora.school;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SchoolApiApplication {
    public static void main(String[] args) {
        SpringApplication.run(SchoolApiApplication.class, args);
    }
}
The entry point: @SpringBootApplication and a one-line main

@SpringBootApplication combines three things: it makes the class a configuration source and switches on the search for components in the package (component scan) and auto-configuration. The latter looks at the classpath: if Spring Web is there, an embedded Tomcat server starts on port 8080 and Jackson is wired in for JSON. You do not have to install a separate server — the app is a JAR with an ordinary main method.

Terminal
./mvnw spring-boot:run
Expected output
... Tomcat started on port 8080 (http) with context path '/'
... Started SchoolApiApplication in 1.9 seconds (process running for 2.2)
Sample output (shortened); with Gradle use ./gradlew bootRun

Layers and dependency injection

LayerAnnotationJob
Controller@RestControllerreceives HTTP requests and returns JSON
Service@Servicebusiness rules: validation, calculations
Repository@Repositorystores and reads data

Spring creates the classes marked with these annotations itself — they are called beans — and keeps them in the IoC container (usually one instance of each class). If a class's constructor needs another bean, Spring passes it in automatically: this is called dependency injection. As a result, classes do not create each other with new, and in a test a dependency is easy to replace with a mock — just like in the JUnit lesson.

The class builds its own dependencies
@RestController
public class StudentController {
    // the controller builds its own dependencies:
    // hard to test, and every class gets its own copy
    private final StudentService service =
            new StudentService(new StudentRepository(), 100);
}
Spring passes the dependency to the constructor
@RestController
public class StudentController {
    private final StudentService service;

    // Spring creates StudentService once and passes it in
    public StudentController(StudentService service) {
        this.service = service;
    }
}

A CRUD API for students

Now let's build a full CRUD API for students: Create, Read, Update, Delete. For simplicity the repository keeps the data in memory, in a ConcurrentHashMap: requests arrive on parallel threads, so a thread-safe collection was chosen.

Java
public record Student(Long id, String name, int score) {}

@Repository
public class StudentRepository {
    private final Map<Long, Student> students = new ConcurrentHashMap<>();
    private final AtomicLong nextId = new AtomicLong(1);

    public List<Student> findAll() {
        return students.values().stream().sorted(Comparator.comparing(Student::id)).toList();
    }

    public Optional<Student> findById(long id) {
        return Optional.ofNullable(students.get(id));
    }

    public Student save(Student s) {
        long id = s.id() != null ? s.id() : nextId.getAndIncrement();
        Student saved = new Student(id, s.name(), s.score());
        students.put(id, saved);
        return saved;
    }

    public boolean deleteById(long id) {
        return students.remove(id) != null;
    }
}
The model and the repository (imports omitted for brevity)
Java
@Service
public class StudentService {
    private final StudentRepository repository;
    private final int maxScore;

    public StudentService(StudentRepository repository,
                          @Value("${school.max-score:100}") int maxScore) {
        this.repository = repository;
        this.maxScore = maxScore;
    }

    public List<Student> all() { return repository.findAll(); }

    public Student get(long id) {
        return repository.findById(id).orElseThrow(() ->
                new ResponseStatusException(HttpStatus.NOT_FOUND, "Student " + id + " not found"));
    }

    public Student save(Long id, Student s) {
        if (s.score() < 0 || s.score() > maxScore)
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Score must be 0-" + maxScore);
        if (id != null) get(id);                  // 404 if we update a missing student
        return repository.save(new Student(id, s.name(), s.score()));
    }

    public void delete(long id) {
        if (!repository.deleteById(id)) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
    }
}
The service: validation rules and school.max-score from application.properties
Java
@RestController
@RequestMapping("/api/students")
public class StudentController {
    private final StudentService service;

    public StudentController(StudentService service) {
        this.service = service;
    }

    @GetMapping
    public List<Student> all() { return service.all(); }

    @GetMapping("/{id}")
    public Student one(@PathVariable long id) { return service.get(id); }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public Student create(@RequestBody Student student) { return service.save(null, student); }

    @PutMapping("/{id}")
    public Student update(@PathVariable long id, @RequestBody Student student) {
        return service.save(id, student);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable long id) { service.delete(id); }
}
The controller: each method is bound to one HTTP operation
RequestWhat it doesSuccess status
GET /api/studentsall students200 OK
GET /api/students/{id}one student; 404 if missing200 OK
POST /api/studentscreates a student201 Created
PUT /api/students/{id}updates a student200 OK
DELETE /api/students/{id}deletes a student204 No Content

The table shows the main REST conventions: the URL names a resource with a plural noun (/api/students), and the HTTP method says what to do. That is why paths like /api/getStudents or /api/deleteStudent?id=2 are considered bad style. GET must never change data, PUT replaces the resource as a whole, and the status code reports the result — the client knows what happened without reading the response text.

@RequestMapping sets the common path for the whole controller. @PathVariable turns the {id} in the URL into a parameter, and @RequestBody turns the JSON body of the request into a Student object; the Jackson library turns the response object back into JSON. ResponseStatusException lets you return the right HTTP status — such as 404 or 400 — from anywhere. @Value("${school.max-score:100}") reads a setting from the configuration; the 100 after the colon is the default value.

Text
spring.application.name=school-api
server.port=8080
school.max-score=100
src/main/resources/application.properties — the app's settings

Settings can be changed without touching the code: java -jar school.jar --server.port=9090 or the SERVER_PORT environment variable. For different environments there are profiles: for example, the application-dev.properties file is activated with --spring.profiles.active=dev.

Testing with curl

While the server is running, open a second terminal. curl is a command-line tool for sending HTTP requests: -X sets the method, -H a header and -d the body. First let's create two students and read the list:

Terminal
curl -X POST localhost:8080/api/students -H 'Content-Type: application/json' -d '{"name":"Aysel","score":95}'
curl -X POST localhost:8080/api/students -H 'Content-Type: application/json' -d '{"name":"Murad","score":78}'
curl localhost:8080/api/students
curl localhost:8080/api/students/1
Expected output
{"id":1,"name":"Aysel","score":95}
{"id":2,"name":"Murad","score":78}
[{"id":1,"name":"Aysel","score":95},{"id":2,"name":"Murad","score":78}]
{"id":1,"name":"Aysel","score":95}
Sample output: the JSON fields follow the record's order, and the server assigns the id

Now updating, deleting and the error cases. -w '%{http_code}' prints only the status code, and -s -o /dev/null hides the body and progress information:

Terminal
curl -X PUT localhost:8080/api/students/2 -H 'Content-Type: application/json' -d '{"name":"Murad","score":84}'
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:8080/api/students/2
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/api/students/2
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/api/students \
     -H 'Content-Type: application/json' -d '{"name":"Elvin","score":120}'
Expected output
{"id":2,"name":"Murad","score":84}
204
404
400
Sample output: 204 — deleted, 404 — no longer there, 400 — a score of 120 is not accepted
Java
@SpringBootTest
@AutoConfigureMockMvc
class StudentControllerTest {

    @Autowired
    MockMvc mvc;

    @Test
    void createsStudent() throws Exception {
        mvc.perform(post("/api/students")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"name\":\"Aysel\",\"score\":95}"))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.name").value("Aysel"));
    }
}
An automated test: MockMvc imitates an HTTP request without starting a real server (imports omitted)

Key points

  • Spring Boot: @SpringBootApplication + auto-configuration + embedded Tomcat; start it with ./mvnw spring-boot:run.
  • Layers: @RestController → @Service → @Repository; Spring creates the beans and passes them to constructors.
  • @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PathVariable, @RequestBody; Jackson handles the JSON conversion.
  • Correct statuses: 200, 201, 204, 400, 404 — with @ResponseStatus and ResponseStatusException.
  • Settings live in application.properties and are read with @Value; the API is tested with curl or MockMvc.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
Which annotation marks a class that receives HTTP requests and returns JSON?