ProductController.java
package com.albertsilva.dev.asjcatalog.web.controller;
import java.net.URI;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import com.albertsilva.dev.asjcatalog.dto.product.request.ProductCreateRequest;
import com.albertsilva.dev.asjcatalog.dto.product.request.ProductUpdateRequest;
import com.albertsilva.dev.asjcatalog.dto.product.response.ProductDetailsResponse;
import com.albertsilva.dev.asjcatalog.dto.product.response.ProductResponse;
import com.albertsilva.dev.asjcatalog.service.ProductService;
import com.albertsilva.dev.asjcatalog.web.exception.response.ProblemDetails;
import com.albertsilva.dev.asjcatalog.web.exception.response.ValidationError;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
/**
* Endpoints de <b>produtos</b> ({@code /api/v1/products}), delegados a
* {@code ProductService}.
*
* <p>
* <b>Acesso:</b> {@code GET} em {@code /api/v1/products/**} é <b>público por
* URL</b> e os métodos de leitura não têm {@code @PreAuthorize}; o OpenAPI, no
* entanto, marca esses endpoints com exigência de Bearer. Escrita exige
* autenticação (URL) e {@code ADMIN} ou {@code OPERATOR}.
* </p>
*
* <p>
* Verbo de atualização: <b>{@code PUT}</b> (o corpo exige nome e
* {@code categoryIds}); alguns testes existentes usam {@code PATCH}, divergência
* registrada na documentação de baseline.
* </p>
*/
@Tag(name = "Produtos", description = "Operações para consulta, cadastro e gestão de produtos do catálogo.")
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
private static final Logger logger = LoggerFactory.getLogger(ProductController.class);
private final ProductService productService;
/**
* @param productService service de produtos
*/
public ProductController(ProductService productService) {
this.productService = productService;
}
/**
* <b>{@code POST /api/v1/products}</b> — cria um produto (ativo) e o vincula às
* categorias.
*
* <ul>
* <li><b>Acesso:</b> autenticado (URL) e {@code ADMIN} ou {@code OPERATOR}.</li>
* <li><b>Entrada:</b> {@link ProductCreateRequest} com {@code @Valid} (nome,
* {@code categoryIds} não vazio; {@code @ProductCreateValid}: nome único e
* categorias existentes). O campo {@code date} é validado, mas descartado.</li>
* <li><b>Fluxo:</b> {@code ProductService.create}.</li>
* <li><b>Sucesso:</b> {@code 201} com {@link ProductResponse} e
* {@code Location} = URL da requisição + {@code /{id}}.</li>
* <li><b>Erros:</b> {@code 422} (validação); {@code 404} (categoria não
* encontrada no service); {@code 403}; {@code 409}.</li>
* </ul>
*
* @param productCreateRequest dados do produto
* @return resposta {@code 201} com o produto criado
*/
@Operation(summary = "Cria um novo produto", description = "Cria um novo produto no catálogo e retorna o recurso criado. Requer autenticação com Bearer Token e permissão ADMIN ou OPERATOR.", security = @SecurityRequirement(name = "security"))
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Produto criado com sucesso", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProductResponse.class))),
@ApiResponse(responseCode = "400", description = "Requisição inválida", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "401", description = "Usuário não autenticado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "403", description = "Acesso negado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "409", description = "Conflito de dados", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "422", description = "Erro de validação", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ValidationError.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@PostMapping
@PreAuthorize("hasRole('ADMIN') or hasRole('OPERATOR')")
public ResponseEntity<ProductResponse> create(@Valid @RequestBody ProductCreateRequest productCreateRequest) {
logger.debug("Recebendo requisição para criar produto: {}", productCreateRequest);
ProductResponse productResponse = productService.create(productCreateRequest);
URI uri = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(productResponse.id())
.toUri();
logger.info("Produto criado com sucesso. id={}", productResponse.id());
return ResponseEntity.created(uri).body(productResponse);
}
/**
* <b>{@code GET /api/v1/products}</b> — lista paginada com filtros por nome e
* por categorias.
*
* <ul>
* <li><b>Acesso:</b> público por URL; sem {@code @PreAuthorize}.</li>
* <li><b>Parâmetros:</b> {@code name} (padrão {@code ""}); {@code categoryIds}
* (texto, padrão {@code "0"} = sem filtro; ids separados por vírgula); e
* {@code page}, {@code size}, {@code sort} do Spring Data (a ordenação só
* atinge as colunas {@code id} e {@code name}).</li>
* <li><b>Fluxo:</b> {@code ProductService.findAllPaged} (consulta nativa +
* {@code JOIN FETCH}).</li>
* <li><b>Sucesso:</b> {@code 200} com {@code Page<ProductResponse>}.</li>
* <li><b>Erros:</b> {@code categoryIds} não numérico gera
* {@code NumberFormatException} ({@code 500}, sem handler específico).</li>
* </ul>
*
* @param name trecho do nome
* @param categoryIds ids de categorias separados por vírgula, ou {@code "0"}
* @param pageable página, tamanho e ordenação
* @return resposta {@code 200} com a página de produtos
*/
@Operation(summary = "Lista produtos com paginação", description = "Retorna uma página de produtos filtrados por nome e categorias. Requer autenticação com Bearer Token.", security = @SecurityRequirement(name = "security"))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Lista paginada de produtos", content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = ProductResponse.class)))),
@ApiResponse(responseCode = "401", description = "Usuário não autenticado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "403", description = "Acesso negado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@GetMapping
public ResponseEntity<Page<ProductResponse>> findAll(
@RequestParam(value = "name", defaultValue = "") String name,
@RequestParam(value = "categoryIds", defaultValue = "0") String categoryIds,
Pageable pageable) {
logger.debug("Buscando produtos paginados - name: {}, page: {}, size: {}, sort: {}",
name,
pageable.getPageNumber(),
pageable.getPageSize(),
pageable.getSort().isSorted() ? pageable.getSort() : "unsorted");
Page<ProductResponse> response = productService.findAllPaged(name, categoryIds, pageable);
logger.debug("Produtos retornados: {}", response.getTotalElements());
return ResponseEntity.ok(response);
}
/**
* <b>{@code GET /api/v1/products/{id}}</b> — detalhes de um produto. Acesso:
* público por URL; sem {@code @PreAuthorize}. Fluxo:
* {@code ProductService.findById}. Sucesso: {@code 200} com
* {@link ProductDetailsResponse}. Erros: {@code 404}; id não numérico vira
* {@code 500}.
*
* @param id identificador do produto
* @return resposta {@code 200} com os detalhes
*/
@Operation(summary = "Busca um produto pelo ID", description = "Retorna os detalhes completos de um produto existente. Requer autenticação com Bearer Token.", security = @SecurityRequirement(name = "security"))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Produto encontrado com sucesso", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProductDetailsResponse.class))),
@ApiResponse(responseCode = "401", description = "Usuário não autenticado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "403", description = "Acesso negado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "404", description = "Recurso não encontrado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@GetMapping(value = "/{id}")
public ResponseEntity<ProductDetailsResponse> findById(@PathVariable Long id) {
logger.debug("Buscando produto por id: {}", id);
ProductDetailsResponse response = productService.findById(id);
logger.debug("Produto encontrado: id={}", id);
return ResponseEntity.ok(response);
}
/**
* <b>{@code PUT /api/v1/products/{id}}</b> — atualiza o produto e substitui suas
* categorias.
*
* <ul>
* <li><b>Acesso:</b> autenticado (URL) e {@code ADMIN} ou {@code OPERATOR}.</li>
* <li><b>Entrada:</b> {@code @PathVariable Long id} e {@link ProductUpdateRequest}
* com {@code @Valid} ({@code @ProductUpdateValid}: nome de outro produto, com o
* {@code id} da URL; categorias existentes). Nome e {@code categoryIds} são
* obrigatórios; descrição, preço e URL só sobrescrevem se não forem
* nulos.</li>
* <li><b>Fluxo:</b> {@code ProductService.update}.</li>
* <li><b>Sucesso:</b> {@code 200} com {@link ProductResponse}.</li>
* <li><b>Erros:</b> {@code 422}; {@code 404}; {@code 403}. Um {@code PATCH} para
* esta rota não tem mapeamento: {@code 405} do Spring, que o handler genérico
* converte em {@code 500}.</li>
* </ul>
*
* @param id identificador do produto
* @param productUpdateRequest novos dados
* @return resposta {@code 200} com o produto atualizado
*/
@Operation(summary = "Atualiza um produto", description = "Atualiza os dados de um produto existente. Requer autenticação com Bearer Token e permissão ADMIN ou OPERATOR.", security = @SecurityRequirement(name = "security"))
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Produto atualizado com sucesso", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProductResponse.class))),
@ApiResponse(responseCode = "400", description = "Requisição inválida", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "401", description = "Usuário não autenticado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "403", description = "Acesso negado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "404", description = "Recurso não encontrado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "422", description = "Erro de validação", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ValidationError.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@PutMapping(value = "/{id}")
@PreAuthorize("hasRole('ADMIN') or hasRole('OPERATOR')")
public ResponseEntity<ProductResponse> update(@PathVariable Long id,
@Valid @RequestBody ProductUpdateRequest productUpdateRequest) {
logger.debug("Atualizando produto id={} com dados: {}", id, productUpdateRequest);
ProductResponse response = productService.update(id, productUpdateRequest);
logger.info("Produto atualizado com sucesso. id={}", id);
return ResponseEntity.ok(response);
}
/**
* <b>{@code PATCH /api/v1/products/{id}/activate}</b> — marca o produto como
* ativo (sem corpo). Acesso: autenticado (URL) e {@code ADMIN} ou
* {@code OPERATOR}. Fluxo: {@code ProductService.activate}. Sucesso:
* {@code 204}. Erros: {@code 404}, {@code 403}.
*
* @param id identificador do produto
* @return resposta {@code 204}
*/
@Operation(summary = "Ativa um produto", description = "Ativa um produto existente no catálogo. Requer autenticação com Bearer Token e permissão ADMIN ou OPERATOR.", security = @SecurityRequirement(name = "security"))
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Produto ativado com sucesso"),
@ApiResponse(responseCode = "401", description = "Usuário não autenticado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "403", description = "Acesso negado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "404", description = "Recurso não encontrado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@PatchMapping("/{id}/activate")
@PreAuthorize("hasRole('ADMIN') or hasRole('OPERATOR')")
public ResponseEntity<Void> activate(@PathVariable Long id) {
logger.debug("Ativando produto id={}", id);
productService.activate(id);
logger.info("Produto ativado com sucesso. id={}", id);
return ResponseEntity.noContent().build();
}
/**
* <b>{@code PATCH /api/v1/products/{id}/deactivate}</b> — marca o produto como
* inativo (sem corpo). Acesso: autenticado (URL) e {@code ADMIN} ou
* {@code OPERATOR}. Fluxo: {@code ProductService.deactivate}. Sucesso:
* {@code 204}. Erros: {@code 404}, {@code 403}. Produtos inativos continuam
* aparecendo na listagem.
*
* @param id identificador do produto
* @return resposta {@code 204}
*/
@Operation(summary = "Desativa um produto", description = "Desativa um produto existente no catálogo. Requer autenticação com Bearer Token e permissão ADMIN ou OPERATOR.", security = @SecurityRequirement(name = "security"))
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Produto desativado com sucesso"),
@ApiResponse(responseCode = "401", description = "Usuário não autenticado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "403", description = "Acesso negado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "404", description = "Recurso não encontrado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@PatchMapping("/{id}/deactivate")
@PreAuthorize("hasRole('ADMIN') or hasRole('OPERATOR')")
public ResponseEntity<Void> deactivate(@PathVariable Long id) {
logger.debug("Desativando produto id={}", id);
productService.deactivate(id);
logger.info("Produto desativado com sucesso. id={}", id);
return ResponseEntity.noContent().build();
}
/**
* <b>{@code DELETE /api/v1/products/{id}}</b> — remove fisicamente o produto.
* Acesso: autenticado (URL) e {@code ADMIN} ou {@code OPERATOR}. Fluxo:
* {@code ProductService.delete}. Sucesso: {@code 204}. Erros: {@code 404};
* {@code 400} ({@code DatabaseException}, quando a violação é detectada dentro
* do método do service); {@code 409}; {@code 403}.
*
* @param id identificador do produto
* @return resposta {@code 204}
*/
@Operation(summary = "Remove um produto", description = "Remove um produto existente do catálogo. Requer autenticação com Bearer Token e permissão ADMIN ou OPERATOR.", security = @SecurityRequirement(name = "security"))
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Produto deletado com sucesso"),
@ApiResponse(responseCode = "401", description = "Usuário não autenticado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "403", description = "Acesso negado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "404", description = "Recurso não encontrado", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "409", description = "Conflito de dados", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class))),
@ApiResponse(responseCode = "500", description = "Erro interno do servidor", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ProblemDetails.class)))
})
@DeleteMapping(value = "/{id}")
@PreAuthorize("hasRole('ADMIN') or hasRole('OPERATOR')")
public ResponseEntity<Void> delete(@PathVariable Long id) {
logger.debug("Deletando produto id={}", id);
productService.delete(id);
logger.info("Produto deletado com sucesso. id={}", id);
return ResponseEntity.noContent().build();
}
}