Full Product CRUD

The Complete Controller

Putting it together: a working Product CRUD API. To keep this page focused, the data lives in a thread-safe map inside the controller. Later modules move it into a service, a repository and a real database.

package com.shopease.product;

import java.math.BigDecimal;
import java.net.URI;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

@RestController
@RequestMapping("/api/products")
public class ProductController {

    private final Map<Long, Product> store = new ConcurrentHashMap<>();
    private final AtomicLong ids = new AtomicLong();

    @GetMapping
    public List<Product> list(@RequestParam(required = false) String category) {
        return store.values().stream()
                .filter(p -> category == null || p.category().equalsIgnoreCase(category))
                .sorted(Comparator.comparing(Product::id))
                .toList();
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> get(@PathVariable Long id) {
        return Optional.ofNullable(store.get(id))
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<Product> create(@RequestBody Product body) {
        long id = ids.incrementAndGet();
        Product saved = new Product(id, body.name(), body.description(),
                body.price(), body.stock(), body.category());
        store.put(id, saved);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(id).toUri();
        return ResponseEntity.created(location).body(saved);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> replace(@PathVariable Long id, @RequestBody Product body) {
        if (!store.containsKey(id)) return ResponseEntity.notFound().build();
        Product updated = new Product(id, body.name(), body.description(),
                body.price(), body.stock(), body.category());
        store.put(id, updated);
        return ResponseEntity.ok(updated);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        return store.remove(id) != null
                ? ResponseEntity.noContent().build()
                : ResponseEntity.notFound().build();
    }
}

record Product(Long id, String name, String description,
               BigDecimal price, int stock, String category) {}

Create and Read

Example

Create two products. Note the 201 status and the Location header:

curl -i -X POST localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Wireless Mouse","description":"2.4 GHz, silent clicks","price":799.00,"stock":120,"category":"Electronics"}'

Example output

HTTP/1.1 201 Created
Location: http://localhost:8080/api/products/1
Content-Type: application/json

{"id":1,"name":"Wireless Mouse","description":"2.4 GHz, silent clicks","price":799.00,"stock":120,"category":"Electronics"}

Example

curl -X POST localhost:8080/api/products -H "Content-Type: application/json" -d '{"name":"Clean Code","description":"Robert C. Martin, paperback","price":450.00,"stock":35,"category":"Books"}'

Example output

{"id":2,"name":"Clean Code","description":"Robert C. Martin, paperback","price":450.00,"stock":35,"category":"Books"}

Example

List all, filter by category, and get one:

curl localhost:8080/api/products
curl "localhost:8080/api/products?category=books"
curl localhost:8080/api/products/1

Example output

[{"id":1,"name":"Wireless Mouse","description":"2.4 GHz, silent clicks","price":799.00,"stock":120,"category":"Electronics"},{"id":2,"name":"Clean Code","description":"Robert C. Martin, paperback","price":450.00,"stock":35,"category":"Books"}]
[{"id":2,"name":"Clean Code","description":"Robert C. Martin, paperback","price":450.00,"stock":35,"category":"Books"}]
{"id":1,"name":"Wireless Mouse","description":"2.4 GHz, silent clicks","price":799.00,"stock":120,"category":"Electronics"}

Update and Delete

Example

curl -X PUT localhost:8080/api/products/1 -H "Content-Type: application/json" -d '{"name":"Wireless Mouse Pro","description":"Bluetooth + 2.4 GHz","price":1199.00,"stock":80,"category":"Electronics"}'

Example output

{"id":1,"name":"Wireless Mouse Pro","description":"Bluetooth + 2.4 GHz","price":1199.00,"stock":80,"category":"Electronics"}

Example

Delete returns 204 with no body. Asking again gives 404:

curl -i -X DELETE localhost:8080/api/products/2
curl -i localhost:8080/api/products/2

Example output

HTTP/1.1 204 No Content

HTTP/1.1 404 Not Found
Content-Length: 0

Exercise: Add GET /api/products/count that returns {"count": 1}. Where must it go so that /count is not treated as an {id}?

Show answer
// Order does not matter: Spring prefers the more specific literal path "/count"
// over the pattern "/{id}", so both can live in the same controller.
@GetMapping("/count")
public Map<String, Integer> count() {
    return Map.of("count", store.size());
}