d5-framework-ui-backend
Possui as classes que expõem um agregado como uma API REST, com Spring Web:
- controlador;
- recurso do agregado;
- recurso de pesquisa;
- tratamento de erros;
- segurança;
- conversão;
- transações;
- open session in view.
Backend
Declara PREFIXO, o prefixo backend/ usado no @RequestMapping de todo controlador do backend.
package dev.sauloaraujo.d5.framework.ui;
public final class Backend {
public static final String PREFIXO = "backend/";
private Backend() {
}
}
Recurso do agregado
IAgregadoRecurso
Representa a resposta de uma operação sobre um único agregado A: o próprio agregado e as autorizações U do usuário sobre ele.
package dev.sauloaraujo.d5.framework.ui;
import dev.sauloaraujo.d5.framework.dominio.IAgregado;
import dev.sauloaraujo.d5.framework.dominio.IAgregadoAutorizacoes;
public interface IAgregadoRecurso<A extends IAgregado<?>, U extends IAgregadoAutorizacoes<A>> {
A getAgregado();
U getAutorizacoes();
}
AgregadoRecurso
Implementação de IAgregadoRecurso que recebe o agregado e as autorizações diretamente pelo construtor.
package dev.sauloaraujo.d5.framework.ui;
import java.util.Objects;
import dev.sauloaraujo.d5.framework.dominio.IAgregado;
import dev.sauloaraujo.d5.framework.dominio.IAgregadoAutorizacoes;
public class AgregadoRecurso<A extends IAgregado<?>, U extends IAgregadoAutorizacoes<A>>
implements IAgregadoRecurso<A, U> {
private final A agregado;
private final U autorizacoes;
public AgregadoRecurso(A agregado, U autorizacoes) {
this.agregado = Objects.requireNonNull(agregado);
this.autorizacoes = Objects.requireNonNull(autorizacoes);
}
public A getAgregado() {
return agregado;
}
@Override
public U getAutorizacoes() {
return autorizacoes;
}
}
Recurso de pesquisa
IPesquisaRecurso
Representa a resposta de uma pesquisa: a consulta C recebida e o resultado de projeções P.
package dev.sauloaraujo.d5.framework.ui;
import dev.sauloaraujo.d5.framework.aplicacao.IConsulta;
import dev.sauloaraujo.d5.framework.aplicacao.IConsultaResultado;
import dev.sauloaraujo.d5.framework.aplicacao.IOrdenacao;
import dev.sauloaraujo.d5.framework.aplicacao.IProjecao;
public interface IPesquisaRecurso<P extends IProjecao<?>, C extends IConsulta<? extends IOrdenacao<P>>> {
C getConsulta();
IConsultaResultado<P> getResultado();
}
PesquisaRecurso
Implementação de IPesquisaRecurso que recebe a consulta e o resultado diretamente pelo construtor.
package dev.sauloaraujo.d5.framework.ui;
import java.util.Objects;
import dev.sauloaraujo.d5.framework.aplicacao.IConsulta;
import dev.sauloaraujo.d5.framework.aplicacao.IConsultaResultado;
import dev.sauloaraujo.d5.framework.aplicacao.IOrdenacao;
import dev.sauloaraujo.d5.framework.aplicacao.IProjecao;
public class PesquisaRecurso<P extends IProjecao<?>, C extends IConsulta<? extends IOrdenacao<P>>>
implements IPesquisaRecurso<P, C> {
private final C consulta;
private final IConsultaResultado<P> resultado;
public PesquisaRecurso(C consulta, IConsultaResultado<P> resultado) {
this.consulta = Objects.requireNonNull(consulta);
this.resultado = Objects.requireNonNull(resultado);
}
public C getConsulta() {
return consulta;
}
public IConsultaResultado<P> getResultado() {
return resultado;
}
}
Controlador
AgregadoControladorAbstrato
Expõe as operações de um serviço e de um serviço de aplicação como endpoints REST. É parametrizada pelo identificador I, pelo agregado A, pelas autorizações U, pela projeção P e pela consulta C, e recebe no construtor o serviço e o serviço de aplicação.
A classe não declara @RestController nem o caminho base dos endpoints: a subclasse deve ser anotada com @RestController e com @RequestMapping(Backend.PREFIXO + "..."). A subclasse não pode ser final, porque os métodos mapeados para POST são envolvidos em uma transação por um proxy que a estende; veja Gerenciamento de transações. Os caminhos abaixo são relativos a esse caminho base:
| Método | Requisição | Corpo da requisição | Resposta |
|---|---|---|---|
inclusao |
GET inclusao |
— | objeto vazio, {} |
incluir |
POST incluir |
agregado | 200, sem corpo |
alteracao |
GET {identificador}/alteracao |
— | recurso do agregado |
alterar |
POST {identificador}/alterar |
agregado | 200, sem corpo |
exclusao |
GET {identificador}/exclusao |
— | recurso do agregado |
excluir |
POST {identificador}/excluir |
— | 200, sem corpo |
pesquisa |
GET pesquisa |
— | recurso de pesquisa |
alteracao e exclusao buscam o agregado através de buscar do serviço — sujeito, portanto, à autorização de busca — e obtêm as autorizações através do método protegido autorizacoes, que delega a criarAutorizacoes do serviço; o cliente usa esse recurso para montar o formulário de alteração ou de confirmação da exclusão. inclusao não consulta o serviço nem verifica autorizações. alterar valida que o identificador do caminho é igual ao do agregado recebido no corpo, lançando IllegalArgumentException — e, portanto, respondendo 422 — caso contrário. pesquisa recebe a consulta como @ModelAttribute, isto é, a partir de parâmetros de requisição individuais.
alteracao, exclusao e pesquisa têm, cada uma, uma sobrecarga protegida — recebendo o agregado e as autorizações, ou a consulta e o resultado, já obtidos — que a subclasse pode sobrescrever para customizar o recurso devolvido. Por padrão, a sobrecarga de exclusao delega à de alteracao.
package dev.sauloaraujo.d5.framework.ui;
import java.util.Objects;
import java.util.Optional;
import org.apache.commons.lang3.Validate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import dev.sauloaraujo.d5.framework.aplicacao.IAgregadoAplicacaoServico;
import dev.sauloaraujo.d5.framework.aplicacao.IConsulta;
import dev.sauloaraujo.d5.framework.aplicacao.IConsultaResultado;
import dev.sauloaraujo.d5.framework.aplicacao.IOrdenacao;
import dev.sauloaraujo.d5.framework.aplicacao.IProjecao;
import dev.sauloaraujo.d5.framework.dominio.IAgregado;
import dev.sauloaraujo.d5.framework.dominio.IAgregadoAutorizacoes;
import dev.sauloaraujo.d5.framework.dominio.IAgregadoServico;
import dev.sauloaraujo.d5.framework.dominio.IIdentificador;
public abstract class AgregadoControladorAbstrato<I extends IIdentificador, A extends IAgregado<I>, U extends IAgregadoAutorizacoes<A>, P extends IProjecao<A>, C extends IConsulta<? extends IOrdenacao<P>>> {
private final IAgregadoServico<I, A, U> servico;
private final IAgregadoAplicacaoServico<P, C> servicoAplicacao;
protected AgregadoControladorAbstrato(IAgregadoServico<I, A, U> servico,
IAgregadoAplicacaoServico<P, C> servicoAplicacao) {
this.servico = Objects.requireNonNull(servico);
this.servicoAplicacao = Objects.requireNonNull(servicoAplicacao);
}
@GetMapping("inclusao")
public Object inclusao() {
return new Object();
}
@PostMapping("incluir")
public void incluir(@RequestBody A agregado) {
Objects.requireNonNull(agregado);
servico.incluir(agregado);
}
@GetMapping("{identificador}/alteracao")
public IAgregadoRecurso<A, U> alteracao(@PathVariable I identificador) {
Objects.requireNonNull(identificador);
var agregado = servico.buscar(identificador);
U autorizacoes = autorizacoes(Optional.of(identificador));
return alteracao(agregado, autorizacoes);
}
protected U autorizacoes(Optional<I> identificador) {
return servico.criarAutorizacoes(identificador);
}
protected IAgregadoRecurso<A, U> alteracao(A agregado, U autorizacoes) {
Objects.requireNonNull(agregado);
return new AgregadoRecurso<>(agregado, autorizacoes);
}
@PostMapping("{identificador}/alterar")
public void alterar(@PathVariable I identificador, @RequestBody A agregado) {
Objects.requireNonNull(identificador);
Objects.requireNonNull(agregado);
Validate.isTrue(Objects.equals(Optional.of(identificador), agregado.getIdentificador()),
"O identificador do caminho difere do identificador do agregado");
servico.alterar(agregado);
}
@GetMapping("{identificador}/exclusao")
public IAgregadoRecurso<A, U> exclusao(@PathVariable I identificador) {
Objects.requireNonNull(identificador);
var agregado = servico.buscar(identificador);
U autorizacoes = autorizacoes(Optional.of(identificador));
return exclusao(agregado, autorizacoes);
}
protected IAgregadoRecurso<A, U> exclusao(A agregado, U autorizacoes) {
return alteracao(agregado, autorizacoes);
}
@PostMapping("{identificador}/excluir")
public void excluir(@PathVariable I identificador) {
Objects.requireNonNull(identificador);
servico.excluir(identificador);
}
@GetMapping("pesquisa")
public IPesquisaRecurso<P, C> pesquisa(@ModelAttribute C consulta) {
Objects.requireNonNull(consulta);
var resultado = servicoAplicacao.pesquisar(consulta);
return pesquisa(consulta, resultado);
}
protected IPesquisaRecurso<P, C> pesquisa(C consulta, IConsultaResultado<P> resultado) {
return new PesquisaRecurso<>(consulta, resultado);
}
}
Os métodos mapeados para POST — incluir, alterar e excluir — são executados dentro de uma transação automaticamente; veja Gerenciamento de transações.
No tutorial, UnidadeFederativaControlador estende AgregadoControladorAbstrato, e MunicipioControlador acrescenta às operações herdadas a ação de extinguir.
Tratamento de erros
ExcecaoManipulador
Converte as exceções lançadas pelos controladores — e, através deles, pelos serviços e repositórios — em uma resposta JSON — {"mensagem": "..."} — com o status HTTP correspondente, sem que controladores, serviços ou repositórios precisem tratar nada:
| Exceção | Status |
|---|---|
AccessDeniedException (Spring Security), NaoAutorizadoExcecao |
403 Forbidden |
AuthenticationException (Spring Security), NaoAutenticadoExcecao |
401 Unauthorized |
IllegalArgumentException, NullPointerException |
422 Unprocessable Content |
IllegalStateException — inclui AgregadoExistenteExcecao |
409 Conflict |
NoSuchElementException — inclui AgregadoInexistenteExcecao |
404 Not Found |
HttpMessageNotReadableException (corpo da requisição ilegível) |
a cadeia de causas é percorrida em busca de uma exceção de argumento, de estado ou de inexistência (veja abaixo); sem correspondência, 400 Bad Request |
TypeMismatchException (parâmetro de caminho ou de consulta não convertido) |
422 Unprocessable Content — veja abaixo a mensagem; exceto ConversionNotSupportedException (nenhum conversor para o tipo do parâmetro), que é um erro de programação: 500 Internal Server Error, registrada em log |
BindException — inclui MethodArgumentNotValidException (parâmetros de um @ModelAttribute não associados) |
para um parâmetro não convertido, o mesmo tratamento de TypeMismatchException, inclusive o 500 de ConversionNotSupportedException; para outros erros de associação, a cadeia de causas é percorrida em busca de uma exceção de argumento, de estado ou de inexistência (veja abaixo); sem correspondência, 400 Bad Request |
BeanInstantiationException (construtor de um @ModelAttribute lançou uma exceção) |
a cadeia de causas é percorrida em busca de uma exceção de argumento, de estado ou de inexistência (veja abaixo); sem correspondência, 500 Internal Server Error, registrada em log |
ConstraintViolationException (Bean Validation) — lançada ao validar uma entidade JPA antes de gravá-la; veja Validação das entidades JPA |
422 Unprocessable Content — veja abaixo a mensagem |
TransactionSystemException (Spring) — lançada, por exemplo, quando a validação de uma entidade JPA ocorre na confirmação da transação |
se a cadeia de causas contém uma ConstraintViolationException, o mesmo tratamento dela; caso contrário, 500 Internal Server Error, registrada em log |
DataIntegrityViolationException (Spring) |
se a cadeia de causas contém uma SQLException com SQLSTATE da classe 22 (dado inválido) — por exemplo, 22001, valor maior que o tamanho da coluna, e 22003, número fora do intervalo do tipo da coluna —, 422 Unprocessable Content; se contém uma com SQLSTATE da classe 23 (violação de restrição de integridade) — por exemplo, a de uma restrição de unicidade violada por duas requisições concorrentes —, 409 Conflict; caso contrário, 500 Internal Server Error, registrada em log |
exceção do Spring MVC que implementa ErrorResponse com status 4xx — por exemplo, NoResourceFoundException (caminho sem controlador), HttpRequestMethodNotSupportedException (verbo não suportado) e HttpMediaTypeNotSupportedException (Content-Type não suportado) |
o status declarado pela exceção — no exemplo, 404 Not Found, 405 Method Not Allowed e 415 Unsupported Media Type, respectivamente |
qualquer outra Exception |
500 Internal Server Error, registrada em log |
Ao percorrer a cadeia de causas, são procuradas apenas IllegalArgumentException, NullPointerException, IllegalStateException e NoSuchElementException, incluindo suas subclasses; as demais exceções da tabela — como as que respondem 401 e 403 — não são procuradas. Prevalece a primeira dessas exceções encontrada, que responde com o status da tabela e com a sua mensagem. Assim, a mensagem de validação lançada pelo construtor de uma consulta ou de um objeto de valor recebido no corpo chega intacta ao cliente.
Nos casos a seguir, a resposta usa uma mensagem em português em vez da mensagem gerada pela JDK ou pelo Spring, que viria em inglês e com detalhes de implementação:
- quando um parâmetro de caminho ou de consulta não é convertido, a resposta preserva a mensagem do domínio apenas se o parâmetro é um objeto de valor — isto é, uma subclasse de
ObjetoValorStringAbstrato, cujo construtor lançou a exceção. Para os demais tipos —enum, números, booleanos, datas —, a mensagem é fixa e identifica o parâmetro e o valor recebido:Valor inválido para o parâmetro ordenacao: FOO; - a mensagem de uma
ConstraintViolationExceptionreúne as violações, ordenadas e separadas por;, cada uma no formatopropriedade: mensagem— por exemplo,nome: tamanho deve ser entre 0 e 100; numero: tamanho deve ser entre 0 e 10; - quando o banco de dados rejeita um valor com SQLSTATE da classe
22, a resposta tem a mensagem fixaUm ou mais valores informados excedem o tamanho ou o intervalo permitidos., e, com SQLSTATE da classe23,Os dados informados violam uma restrição do banco de dados, como a unicidade de um valor., em vez da mensagem do driver JDBC, que exporia nomes de tabelas e colunas. A classe do SQLSTATE é padronizada, mas a subclasse varia entre os bancos de dados — a violação de unicidade, por exemplo, é23505no PostgreSQL e no H2 e23000no MySQL, no Oracle e no SQL Server —, por isso o tratamento considera apenas a classe. O tratamento da classe22cobre os limites que a validação das entidades JPA não verifica — por exemplo, os de uma entidade sem as anotações do Bean Validation; - as exceções que implementam
ErrorResponserespondem com uma mensagem fixa associada ao status — por exemplo,Recurso não encontrado.para 404,Método HTTP não suportado.para 405 eFormato do corpo da requisição não suportado.para 415; - uma exceção mapeada sem mensagem — como a lançada por
Objects.requireNonNullsem argumento de mensagem — responde com a mesma mensagem fixa associada ao seu status — por exemplo,Dados inválidos.para 422.
AccessDeniedException e AuthenticationException têm tratamento explícito porque, sem ele, uma dessas exceções lançada de dentro de um controlador — por exemplo, por uma verificação de segurança em nível de método — seria capturada pelo handler genérico de Exception e viraria 500 Internal Server Error, em vez de 403 ou 401.
Exceções lançadas fora dos controladores, na cadeia de filtros do Spring Security — como a negação de um endpoint pelas regras de SegurancaConfiguracaoAbstrata, a ausência de autenticação ou a ausência do token de CSRF — não passam por ExcecaoManipulador. Elas também respondem no formato {"mensagem": "..."}, mas através de EntradaAutenticacaoSemDesafio, com 401, e de ManipuladorAcessoNegado, com 403.
package dev.sauloaraujo.d5.framework.ui;
import java.sql.SQLException;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Optional;
import java.util.stream.Collectors;
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanInstantiationException;
import org.springframework.beans.ConversionNotSupportedException;
import org.springframework.beans.TypeMismatchException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.convert.ConversionFailedException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.transaction.TransactionSystemException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.ErrorResponse;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import dev.sauloaraujo.d5.framework.dominio.impl.ObjetoValorStringAbstrato;
import dev.sauloaraujo.d5.framework.dominio.spi.NaoAutenticadoExcecao;
import dev.sauloaraujo.d5.framework.dominio.spi.NaoAutorizadoExcecao;
import jakarta.validation.ConstraintViolationException;
@RestControllerAdvice
@Order(Ordered.LOWEST_PRECEDENCE)
public class ExcecaoManipulador {
private static final Logger LOG = LoggerFactory.getLogger(ExcecaoManipulador.class);
private static final String SQLSTATE_DADO_INVALIDO = "22";
private static final String SQLSTATE_VIOLACAO_INTEGRIDADE = "23";
private static final String VALOR_FORA_DOS_LIMITES = "Um ou mais valores informados excedem o tamanho ou o intervalo permitidos.";
private static final String RESTRICAO_VIOLADA = "Os dados informados violam uma restrição do banco de dados, como a unicidade de um valor.";
@ExceptionHandler(AccessDeniedException.class)
ResponseEntity<Object> tratarAcessoNegado(AccessDeniedException excecao) {
return corpo(HttpStatus.FORBIDDEN, excecao);
}
@ExceptionHandler(NaoAutorizadoExcecao.class)
ResponseEntity<Object> tratarAcessoNegado(NaoAutorizadoExcecao excecao) {
return corpo(HttpStatus.FORBIDDEN, excecao);
}
@ExceptionHandler(AuthenticationException.class)
ResponseEntity<Object> tratarNaoAutenticado(AuthenticationException excecao) {
return corpo(HttpStatus.UNAUTHORIZED, excecao);
}
@ExceptionHandler(NaoAutenticadoExcecao.class)
ResponseEntity<Object> tratarNaoAutenticado(NaoAutenticadoExcecao excecao) {
return corpo(HttpStatus.UNAUTHORIZED, excecao);
}
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<Object> tratarArgumentoInvalido(IllegalArgumentException excecao) {
return corpo(HttpStatus.UNPROCESSABLE_CONTENT, excecao);
}
@ExceptionHandler(NullPointerException.class)
ResponseEntity<Object> tratarValorAusente(NullPointerException excecao) {
return corpo(HttpStatus.UNPROCESSABLE_CONTENT, excecao);
}
@ExceptionHandler(IllegalStateException.class)
ResponseEntity<Object> tratarConflito(IllegalStateException excecao) {
return corpo(HttpStatus.CONFLICT, excecao);
}
@ExceptionHandler(NoSuchElementException.class)
ResponseEntity<Object> tratarInexistente(NoSuchElementException excecao) {
return corpo(HttpStatus.NOT_FOUND, excecao);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<Object> tratarCorpoInvalido(HttpMessageNotReadableException excecao) {
return tratarCausa(excecao.getCause())
.orElseGet(() -> corpo(HttpStatus.BAD_REQUEST, "Corpo da requisição inválido."));
}
@ExceptionHandler(TypeMismatchException.class)
ResponseEntity<Object> tratarParametroInvalido(TypeMismatchException excecao) {
return tratarConversao(excecao, excecao.getPropertyName(), excecao.getValue());
}
@ExceptionHandler(BindException.class)
ResponseEntity<Object> tratarParametrosInvalidos(BindException excecao) {
for (var erro : excecao.getAllErrors()) {
if (erro instanceof FieldError erroCampo && erroCampo.contains(TypeMismatchException.class)) {
return tratarConversao(erroCampo.unwrap(TypeMismatchException.class), erroCampo.getField(),
erroCampo.getRejectedValue());
}
if (erro.contains(Throwable.class)) {
var resposta = tratarCausa(erro.unwrap(Throwable.class));
if (resposta.isPresent()) {
return resposta.get();
}
}
}
return corpo(HttpStatus.BAD_REQUEST, "Parâmetros da requisição inválidos.");
}
@ExceptionHandler(DataIntegrityViolationException.class)
ResponseEntity<Object> tratarViolacaoIntegridade(DataIntegrityViolationException excecao) {
for (Throwable causa = excecao; causa != null; causa = causa.getCause()) {
if (causa instanceof SQLException sqlException) {
var estado = sqlException.getSQLState();
if (estado != null && estado.startsWith(SQLSTATE_DADO_INVALIDO)) {
return corpo(HttpStatus.UNPROCESSABLE_CONTENT, VALOR_FORA_DOS_LIMITES);
}
if (estado != null && estado.startsWith(SQLSTATE_VIOLACAO_INTEGRIDADE)) {
return corpo(HttpStatus.CONFLICT, RESTRICAO_VIOLADA);
}
}
}
return tratarExcecaoNaoMapeada(excecao);
}
@ExceptionHandler(ConstraintViolationException.class)
ResponseEntity<Object> tratarRestricaoViolada(ConstraintViolationException excecao) {
var mensagem = excecao.getConstraintViolations().stream()
.map(violacao -> violacao.getPropertyPath() + ": " + violacao.getMessage()).sorted()
.collect(Collectors.joining("; "));
return corpo(HttpStatus.UNPROCESSABLE_CONTENT, mensagem);
}
@ExceptionHandler(TransactionSystemException.class)
ResponseEntity<Object> tratarFalhaTransacao(TransactionSystemException excecao) {
for (Throwable causa = excecao; causa != null; causa = causa.getCause()) {
if (causa instanceof ConstraintViolationException restricaoViolada) {
return tratarRestricaoViolada(restricaoViolada);
}
}
return tratarExcecaoNaoMapeada(excecao);
}
@ExceptionHandler(BeanInstantiationException.class)
ResponseEntity<Object> tratarInstanciacaoInvalida(BeanInstantiationException excecao) {
return tratarCausa(excecao.getCause()).orElseGet(() -> tratarExcecaoNaoMapeada(excecao));
}
@ExceptionHandler(Exception.class)
ResponseEntity<Object> tratarExcecaoNaoMapeada(Exception excecao) {
if (excecao instanceof ErrorResponse respostaErro && !respostaErro.getStatusCode().is5xxServerError()) {
return corpo(respostaErro.getStatusCode(), mensagemPadrao(respostaErro.getStatusCode()));
}
LOG.error("Erro não tratado", excecao);
return corpo(HttpStatus.INTERNAL_SERVER_ERROR, mensagemPadrao(HttpStatus.INTERNAL_SERVER_ERROR));
}
private ResponseEntity<Object> tratarConversao(TypeMismatchException excecao, @Nullable String nomeParametro,
@Nullable Object valor) {
if (excecao instanceof ConversionNotSupportedException) {
return tratarExcecaoNaoMapeada(excecao);
}
for (Throwable causa = excecao; causa != null; causa = causa.getCause()) {
if (causa instanceof ConversionFailedException conversao
&& ObjetoValorStringAbstrato.class.isAssignableFrom(conversao.getTargetType().getType())) {
var resposta = tratarCausa(conversao.getCause());
if (resposta.isPresent()) {
return resposta.get();
}
}
}
var parametro = nomeParametro != null ? "o parâmetro " + nomeParametro : "um parâmetro";
var mensagem = "Valor inválido para " + parametro + (valor != null ? ": " + valor : "");
return corpo(HttpStatus.UNPROCESSABLE_CONTENT, mensagem);
}
private Optional<ResponseEntity<Object>> tratarCausa(@Nullable Throwable excecao) {
for (var causa = excecao; causa != null; causa = causa.getCause()) {
if (causa instanceof IllegalStateException illegalStateException) {
return Optional.of(tratarConflito(illegalStateException));
}
if (causa instanceof NoSuchElementException noSuchElementException) {
return Optional.of(tratarInexistente(noSuchElementException));
}
if (causa instanceof IllegalArgumentException illegalArgumentException) {
return Optional.of(tratarArgumentoInvalido(illegalArgumentException));
}
if (causa instanceof NullPointerException nullPointerException) {
return Optional.of(tratarValorAusente(nullPointerException));
}
}
return Optional.empty();
}
private ResponseEntity<Object> corpo(HttpStatus status, Exception excecao) {
return corpo(status, excecao.getMessage());
}
private ResponseEntity<Object> corpo(HttpStatusCode status, @Nullable String mensagem) {
return ResponseEntity.status(status)
.body(Map.of("mensagem", mensagem != null ? mensagem : mensagemPadrao(status)));
}
private static String mensagemPadrao(HttpStatusCode status) {
return switch (status.value()) {
case 400 -> "Requisição inválida.";
case 401 -> "Usuário não autenticado.";
case 403 -> "Ação não autorizada.";
case 404 -> "Recurso não encontrado.";
case 405 -> "Método HTTP não suportado.";
case 406 -> "Formato de resposta não suportado.";
case 409 -> "Conflito com o estado atual do recurso.";
case 415 -> "Formato do corpo da requisição não suportado.";
case 422 -> "Dados inválidos.";
default -> status.is4xxClientError() ? "Requisição inválida." : "Erro interno do servidor.";
};
}
}
Segurança
SegurancaConfiguracaoAbstrata
Configura o Spring Security para o backend através do bean filtroSeguranca, um SecurityFilterChain. A subclasse deve ser anotada com @Configuration e implementar o método abstrato autorizar, que recebe o registro de regras de authorizeHttpRequests. As regras são avaliadas na ordem em que são declaradas, e prevalece a primeira que corresponde à requisição:
- os arquivos do Swagger UI e da documentação OpenAPI — uma lista fixa de caminhos sob
/swagger-ui/e/v3/api-docs, como/swagger-ui/index.htmle/v3/api-docs/swagger-config— são liberados a todos; - as regras declaradas por
autorizar; - qualquer outra requisição sob
BACKEND— a constante protegida"/" + Backend.PREFIXO + "**", no formato exigido porauthorizeHttpRequests— é negada.
Portanto, um endpoint sob Backend.PREFIXO só é acessível se autorizar o liberar explicitamente. Os caminhos do Swagger UI são fixos e correspondem aos padrões do springdoc-openapi; se springdoc.swagger-ui.path ou springdoc.api-docs.path forem alterados, o Swagger UI deixa de ser liberado.
Além disso, filtroSeguranca:
- autentica via HTTP Basic, com
EntradaAutenticacaoSemDesafiono lugar do desafio padrão; - adota a política de sessão
SessionCreationPolicy.IF_REQUIRED; - configura o logout, através de
POSTemSAIR— a constante protegida"/" + Backend.PREFIXO + "sair"—, que responde 204 No Content. O logout é tratado antes das regras de autorização e não exige autenticação, apenas o token de CSRF, que impede um site de terceiros de encerrar a sessão do usuário; sem sessão, também responde 204, já que o resultado é o mesmo; - protege contra CSRF com o cookie
XSRF-TOKEN, nãoHttpOnly, lido pelo cliente e devolvido no cabeçalhoX-XSRF-TOKEN, através deManipuladorRequisicaoCsrfSpaeFiltroCookieCsrf. Uma requisição de escrita sem token válido é rejeitada com 403 e a mensagemToken CSRF ausente ou inválido., antes mesmo da verificação das credenciais. Ao autenticar o usuário, o Spring Security renova o token: a resposta à requisição que autentica apaga o cookie, e a resposta seguinte traz um novo; - responde às negações de acesso na cadeia de filtros — por CSRF ou pelas regras de autorização — através de
ManipuladorAcessoNegado.
Também expõe, como bean, o PasswordEncoder devolvido por codificadorSenha, usado para codificar e verificar as senhas dos usuários da aplicação. É um DelegatingPasswordEncoder: codifica com bcrypt e prefixa a senha codificada com o identificador do algoritmo — {bcrypt}... —, prefixo que toda senha armazenada deve trazer para ser verificada.
package dev.sauloaraujo.d5.framework.ui.seguranca;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import dev.sauloaraujo.d5.framework.ui.Backend;
public abstract class SegurancaConfiguracaoAbstrata {
protected static final String BACKEND = "/" + Backend.PREFIXO + "**";
protected static final String SAIR = "/" + Backend.PREFIXO + "sair";
private static final String[] SWAGGER = { "/swagger-ui/favicon-16x16.png", "/swagger-ui/favicon-32x32.png",
"/swagger-ui/index.css", "/swagger-ui/index.html", "/swagger-ui/swagger-initializer.js",
"/swagger-ui/swagger-ui-bundle.js", "/swagger-ui/swagger-ui-standalone-preset.js",
"/swagger-ui/swagger-ui.css", "/v3/api-docs", "/v3/api-docs.yaml", "/v3/api-docs/swagger-config" };
@Bean
public SecurityFilterChain filtroSeguranca(HttpSecurity http) {
var entradaSemDesafio = new EntradaAutenticacaoSemDesafio();
// @formatter:off
return http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new ManipuladorRequisicaoCsrfSpa()))
.addFilterAfter(new FiltroCookieCsrf(), BasicAuthenticationFilter.class)
.sessionManagement(sessao -> sessao.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED))
.authorizeHttpRequests(autorizacao -> {
autorizacao.requestMatchers(SWAGGER).permitAll();
autorizar(autorizacao);
autorizacao.requestMatchers(BACKEND).denyAll();
})
.httpBasic(basico -> basico.authenticationEntryPoint(entradaSemDesafio))
.exceptionHandling(excecoes -> excecoes.accessDeniedHandler(new ManipuladorAcessoNegado()))
.logout(logout -> logout
.logoutUrl(SAIR)
.logoutSuccessHandler((requisicao, resposta, autenticacao) -> resposta
.setStatus(HttpStatus.NO_CONTENT.value())))
.build();
// @formatter:on
}
protected abstract void autorizar(
AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry autorizacao);
@Bean
public PasswordEncoder codificadorSenha() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
No tutorial, SegurancaConfiguracao estende SegurancaConfiguracaoAbstrata, implementando autorizar e declarando os usuários da aplicação.
EntradaAutenticacaoSemDesafio
Por padrão, a autenticação HTTP Basic do Spring Security responde a uma requisição não autenticada com o cabeçalho WWW-Authenticate, que faz o navegador abrir o diálogo nativo de login — inadequado para uma SPA ou para o Swagger UI, que devem tratar a autenticação por conta própria. Esta implementação de AuthenticationEntryPoint responde 401 com um corpo JSON, sem esse cabeçalho.
package dev.sauloaraujo.d5.framework.ui.seguranca;
import java.io.IOException;
import java.util.Map;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.ObjectMapper;
public class EntradaAutenticacaoSemDesafio implements AuthenticationEntryPoint {
private static final ObjectMapper JSON = new ObjectMapper();
@Override
public void commence(HttpServletRequest requisicao, HttpServletResponse resposta, AuthenticationException excecao)
throws IOException {
resposta.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
resposta.setContentType("application/json");
JSON.writeValue(resposta.getOutputStream(), Map.of("mensagem", "Usuário não autenticado."));
}
}
ManipuladorAcessoNegado
Por padrão, o Spring Security responde a uma negação de acesso na cadeia de filtros com sendError(403), que encaminha a requisição ao endpoint de erro, /error. Como as regras de SegurancaConfiguracaoAbstrata não liberam /error, esse encaminhamento também seria negado:
- uma falha de CSRF, verificada antes da autenticação, chegaria a
/errorcom o usuário ainda anônimo e resultaria em 401 com a mensagem de usuário não autenticado, mesmo com credenciais válidas; - a negação de um endpoint por
autorizarresultaria em 403, mas com corpo vazio.
Esta implementação de AccessDeniedHandler escreve a resposta diretamente, sem encaminhá-la a /error: 403 com um corpo JSON cuja mensagem é Token CSRF ausente ou inválido., para falhas de CSRF, ou Ação não autorizada., para as demais negações. Assim, o cliente distingue a falta do token — que se resolve obtendo um novo cookie — da falta de credenciais, que responde 401.
package dev.sauloaraujo.d5.framework.ui.seguranca;
import java.io.IOException;
import java.util.Map;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.csrf.CsrfException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import tools.jackson.databind.ObjectMapper;
public class ManipuladorAcessoNegado implements AccessDeniedHandler {
private static final ObjectMapper JSON = new ObjectMapper();
@Override
public void handle(HttpServletRequest requisicao, HttpServletResponse resposta, AccessDeniedException excecao)
throws IOException {
var mensagem = excecao instanceof CsrfException ? "Token CSRF ausente ou inválido." : "Ação não autorizada.";
resposta.setStatus(HttpServletResponse.SC_FORBIDDEN);
resposta.setContentType("application/json");
JSON.writeValue(resposta.getOutputStream(), Map.of("mensagem", mensagem));
}
}
FiltroCookieCsrf
O token de CSRF do Spring Security é resolvido de forma preguiçosa: o cookie só é enviado ao cliente se algo, durante o processamento da requisição, efetivamente ler o token. Como o backend não renderiza HTML nem acessa o token em nenhuma outra requisição, o cookie nunca seria criado. Este filtro força essa leitura em toda requisição, logo após a autenticação, garantindo que o cookie já exista desde a primeira resposta.
package dev.sauloaraujo.d5.framework.ui.seguranca;
import java.io.IOException;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class FiltroCookieCsrf extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest requisicao, HttpServletResponse resposta, FilterChain cadeia)
throws ServletException, IOException {
var token = (CsrfToken) requisicao.getAttribute(CsrfToken.class.getName());
if (token != null) {
token.getToken();
}
cadeia.doFilter(requisicao, resposta);
}
}
ManipuladorRequisicaoCsrfSpa
Implementação de CsrfTokenRequestAttributeHandler que segue a recomendação do Spring Security para SPAs: delega a XorCsrfTokenRequestAttributeHandler — que mascara o token para mitigar ataques BREACH — mas, ao resolver o token enviado pelo cliente, usa o valor do cabeçalho X-XSRF-TOKEN sem máscara quando presente, já que é lido diretamente do cookie por JavaScript, em vez de devolvido mascarado por um formulário renderizado pelo servidor.
package dev.sauloaraujo.d5.framework.ui.seguranca;
import java.util.function.Supplier;
import org.jspecify.annotations.Nullable;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.csrf.XorCsrfTokenRequestAttributeHandler;
import org.springframework.util.StringUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
public class ManipuladorRequisicaoCsrfSpa extends CsrfTokenRequestAttributeHandler {
private final CsrfTokenRequestAttributeHandler delegado = new XorCsrfTokenRequestAttributeHandler();
@Override
public void handle(HttpServletRequest requisicao, HttpServletResponse resposta, Supplier<CsrfToken> token) {
delegado.handle(requisicao, resposta, token);
}
@Override
@Nullable
public String resolveCsrfTokenValue(HttpServletRequest requisicao, CsrfToken token) {
var valorHeader = requisicao.getHeader(token.getHeaderName());
return StringUtils.hasText(valorHeader) ? super.resolveCsrfTokenValue(requisicao, token)
: delegado.resolveCsrfTokenValue(requisicao, token);
}
}
A seção CSRF no Swagger UI descreve o efeito de FiltroCookieCsrf e ManipuladorRequisicaoCsrfSpa na prática.
AutenticacaoControladorAbstrato
Expõe, através de GET no caminho base da subclasse, o recurso devolvido pelo método protegido recurso — permitindo ao cliente descobrir, por exemplo, o nome do usuário autenticado. A subclasse deve implementar recurso, devolvendo uma subclasse de AutenticacaoRecursoAbstrato com o usuário obtido do ISegurancaServico recebido no construtor, disponível às subclasses pelo atributo protegido segurancaServico, e com as demais informações que o cliente precisar, como as autorizações de cada agregado. Assim como em AgregadoControladorAbstrato, a subclasse deve ser anotada com @RestController e @RequestMapping. Sem usuário autenticado, a resposta é 401.
package dev.sauloaraujo.d5.framework.ui.seguranca;
import java.util.Objects;
import org.springframework.web.bind.annotation.GetMapping;
import dev.sauloaraujo.d5.framework.dominio.spi.ISegurancaServico;
public abstract class AutenticacaoControladorAbstrato {
protected final ISegurancaServico segurancaServico;
protected AutenticacaoControladorAbstrato(ISegurancaServico segurancaServico) {
this.segurancaServico = Objects.requireNonNull(segurancaServico);
}
@GetMapping
public AutenticacaoRecursoAbstrato autenticacao() {
return recurso();
}
protected abstract AutenticacaoRecursoAbstrato recurso();
}
No tutorial, AutenticacaoControlador estende AutenticacaoControladorAbstrato.
AutenticacaoRecursoAbstrato
Base do recurso devolvido por AutenticacaoControladorAbstrato, com o nome do usuário autenticado. A subclasse acrescenta os atributos que o cliente precisar conhecer, como as autorizações de cada agregado.
package dev.sauloaraujo.d5.framework.ui.seguranca;
import java.util.Objects;
public abstract class AutenticacaoRecursoAbstrato {
private final String usuario;
protected AutenticacaoRecursoAbstrato(String usuario) {
this.usuario = Objects.requireNonNull(usuario);
}
public String getUsuario() {
return usuario;
}
}
Conversão
ConversaoConfiguracao e JacksonConfiguracao
Fazem todo objeto de valor que estende ObjetoValorStringAbstrato trafegar como a String que representa — sem que a aplicação escreva nenhum código de conversão. JacksonConfiguracao o serializa como essa String no corpo JSON, através de ObjetoValorStringMixin; ConversaoConfiguracao registra ObjetoValorStringConversorFactory, que o converte a partir de parâmetros de caminho e de consulta. Veja Serialização de objetos de valor.
Transações
TransacaoConfiguracao e PostMappingTransactionAttributeSource
Envolvem em uma transação todo método de controlador mapeado para POST, sem @Transactional nem @EnableTransactionManagement. Veja Gerenciamento de transações.
Open session in view
JpaEnvironmentPostProcessor
Define false como valor padrão da propriedade spring.jpa.open-in-view, desativando o padrão open session in view. Veja Open session in view desativado.