Pular para conteúdo

d5-framework-ui-backend

Possui as classes que expõem um agregado como uma API REST, com Spring Web:

Backend

Declara PREFIXO, o prefixo backend/ usado no @RequestMapping de todo controlador do backend.

Backend.java
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.

IAgregadoRecurso.java
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.

AgregadoRecurso.java
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.

IPesquisaRecurso.java
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.

PesquisaRecurso.java
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.

AgregadoControladorAbstrato.java
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 ConstraintViolationException reúne as violações, ordenadas e separadas por ;, cada uma no formato propriedade: 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 fixa Um ou mais valores informados excedem o tamanho ou o intervalo permitidos., e, com SQLSTATE da classe 23, 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, é 23505 no PostgreSQL e no H2 e 23000 no MySQL, no Oracle e no SQL Server —, por isso o tratamento considera apenas a classe. O tratamento da classe 22 cobre 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 ErrorResponse respondem com uma mensagem fixa associada ao status — por exemplo, Recurso não encontrado. para 404, Método HTTP não suportado. para 405 e Formato do corpo da requisição não suportado. para 415;
  • uma exceção mapeada sem mensagem — como a lançada por Objects.requireNonNull sem 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.

ExcecaoManipulador.java
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:

  1. 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.html e /v3/api-docs/swagger-config — são liberados a todos;
  2. as regras declaradas por autorizar;
  3. qualquer outra requisição sob BACKEND — a constante protegida "/" + Backend.PREFIXO + "**", no formato exigido por authorizeHttpRequests — é 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 EntradaAutenticacaoSemDesafio no lugar do desafio padrão;
  • adota a política de sessão SessionCreationPolicy.IF_REQUIRED;
  • configura o logout, através de POST em SAIR — 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ão HttpOnly, lido pelo cliente e devolvido no cabeçalho X-XSRF-TOKEN, através de ManipuladorRequisicaoCsrfSpa e FiltroCookieCsrf. Uma requisição de escrita sem token válido é rejeitada com 403 e a mensagem Token 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.

SegurancaConfiguracaoAbstrata.java
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.

EntradaAutenticacaoSemDesafio.java
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 /error com 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 autorizar resultaria 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.

ManipuladorAcessoNegado.java
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.

FiltroCookieCsrf.java
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.

ManipuladorRequisicaoCsrfSpa.java
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.

AutenticacaoControladorAbstrato.java
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.

AutenticacaoRecursoAbstrato.java
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.