ProductController.java
package com.rbctcsworld.ecommerce.product;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* GET /api/products?q=&page=0&size=20&sort=id|name|price_asc|price_desc
* public 200 (JSON array = one page) | 400 bad page/size/sort
* headers: X-Total-Count, X-Total-Pages, X-Page, X-Page-Size, Link (rel="next"/"prev")
* The body stayed an array, so older clients keep working (backward-compatible fix of DEF-007).
* &grouped=true: one card per variant group (the storefront)
* &category=a,b &minRating=4 &colors=Navy,Red &sizes=M,L
* GET /api/products/facets?(same filters) public 200 counts for the filter panel | 400 bad filter
* GET /api/products/{id} public 200 | 404
* GET /api/products/{id}/bought-together?limit=4 public 200 products often bought in the same order | 400 | 404
* GET /api/products/deals public 200 flash deals running now, the one ending first on top (at most 12)
* GET /api/products/{id}/variants public 200 every size/colour of the item | 404
* GET /api/products/variant-groups?names=A,B public 200 option summary per group | 400 more than 100
* POST /api/products ADMIN 201 | 400 | 401 | 403 | 409
* PUT /api/products/{id} ADMIN 200 | 400 | 401 | 403 | 404 | 409
* DELETE /api/products/{id} ADMIN (soft) 204 | 401 | 403 | 404
*/
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
private final ProductImagePolicy images;
public ProductController(ProductService service, ProductImagePolicy images) {
this.service = service;
this.images = images;
}
@GetMapping
public ResponseEntity<List<Product>> all(@RequestParam(required = false) String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "" + ProductService.DEFAULT_PAGE_SIZE) int size,
@RequestParam(defaultValue = "id") String sort,
@RequestParam(required = false) String category,
@RequestParam(required = false) java.math.BigDecimal minPrice,
@RequestParam(required = false) java.math.BigDecimal maxPrice,
@RequestParam(required = false) Boolean inStock,
@RequestParam(required = false) Boolean grouped,
@RequestParam(required = false) java.math.BigDecimal minRating,
@RequestParam(required = false) List<String> colors,
@RequestParam(required = false) List<String> sizes) {
Page<Product> result = service.page(q, page, size, sort,
new ProductFilter(category, minPrice, maxPrice, inStock, grouped, minRating, colors, sizes));
ResponseEntity.BodyBuilder response = ResponseEntity.ok()
.header("X-Total-Count", String.valueOf(result.getTotalElements()))
.header("X-Total-Pages", String.valueOf(result.getTotalPages()))
.header("X-Page", String.valueOf(result.getNumber()))
.header("X-Page-Size", String.valueOf(result.getSize()));
String links = links(result);
if (!links.isEmpty()) response.header("Link", links);
return response.body(result.getContent());
}
/** RFC 8288 Link header, like the GitHub API: <...?page=1>; rel="next", <...?page=0>; rel="prev". */
private static String links(Page<?> p) {
StringBuilder sb = new StringBuilder();
if (p.hasNext()) sb.append(link(p.getNumber() + 1, "next"));
if (p.hasPrevious()) sb.append(sb.isEmpty() ? "" : ", ").append(link(p.getNumber() - 1, "prev"));
return sb.toString();
}
private static String link(int page, String rel) {
String url = ServletUriComponentsBuilder.fromCurrentRequest().replaceQueryParam("page", page).build().toUriString();
return "<" + url + ">; rel=\"" + rel + "\"";
}
/** The categories of the products on sale (for the category filter). */
@GetMapping("/categories")
public List<String> categories() {
return service.categories();
}
/** "Customers who bought this also bought": products from the same orders, most frequent first. */
@GetMapping("/{id}/bought-together")
public List<Product> boughtTogether(@PathVariable long id, @RequestParam(required = false) Integer limit) {
return service.boughtTogether(id, limit);
}
@GetMapping("/deals")
public List<Product> deals() {
return service.liveDeals();
}
/** "Similar products" for the product page: same category, in stock first, closest price. */
@GetMapping("/{id}/similar")
public List<Product> similar(@PathVariable long id, @RequestParam(required = false) Integer limit) {
return service.similar(id, limit);
}
/** Counts for the catalog's filter panel (disjunctive facets, items not variants). */
@GetMapping("/facets")
public Facets facets(@RequestParam(required = false) String q,
@RequestParam(required = false) String category,
@RequestParam(required = false) java.math.BigDecimal minPrice,
@RequestParam(required = false) java.math.BigDecimal maxPrice,
@RequestParam(required = false) Boolean inStock,
@RequestParam(required = false) java.math.BigDecimal minRating,
@RequestParam(required = false) List<String> colors,
@RequestParam(required = false) List<String> sizes) {
return service.facets(q, new ProductFilter(category, minPrice, maxPrice, inStock, true, minRating, colors, sizes));
}
/** Every size and colour of the item this product belongs to (itself included), for the product page. */
@GetMapping("/{id}/variants")
public List<Product> variants(@PathVariable long id) {
return service.variants(id);
}
/** Option summaries for catalog cards, one request for a whole page of cards. */
@GetMapping("/variant-groups")
public List<VariantSummary> variantGroups(@RequestParam(required = false) List<String> names) {
return service.variantSummaries(names);
}
@GetMapping("/{id}")
public Product get(@PathVariable long id) {
return service.get(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Product create(@Valid @RequestBody ProductRequest r) {
images.check(r.imageUrl());
return service.create(r);
}
@PutMapping("/{id}")
public Product update(@PathVariable long id, @Valid @RequestBody ProductRequest r) {
images.check(r.imageUrl());
return service.update(id, r);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable long id) {
service.delete(id);
}
}