Careeroza
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/1Example 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/2Example 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());
}