Demoiselle Framework v4 — Modernização Jakarta EE 10

O Demoiselle Framework v4 foi modernizado para aproveitar plenamente os recursos do Jakarta EE 10, CDI 4.0 e Java 21. Esta documentação cobre as funcionalidades atuais e as orientações de migração.

← Voltar ao portal do projeto · Migração 4.1 · Extensões de produção

Início rápido

Requisitos: Java 21+, Maven 3.9+ e um runtime Jakarta EE 10. Em aplicações, prefira importar o BOM em vez de repetir versões por módulo:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.demoiselle.jee</groupId>
      <artifactId>demoiselle-parent-bom</artifactId>
      <version>4.1.0-SNAPSHOT</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.demoiselle.jee</groupId>
    <artifactId>demoiselle-core</artifactId>
  </dependency>
  <dependency>
    <groupId>org.demoiselle.jee</groupId>
    <artifactId>demoiselle-rest</artifactId>
  </dependency>
  <dependency>
    <groupId>org.demoiselle.jee</groupId>
    <artifactId>demoiselle-crud</artifactId>
  </dependency>
</dependencies>

Para snapshots, use o repositório Sonatype OSS documentado no README no GitHub. O runtime fornece as APIs Jakarta EE; não empacote uma implementação completa da plataforma dentro da aplicação. O status das entregas e as extensões que dependem do ambiente do produto estão registrados no roadmap técnico.

🌐 Conformidade com Padrões IETF

O framework v4 implementa conformidade com três RFCs, garantindo interoperabilidade com qualquer cliente HTTP que siga os padrões:

RFC Padrão Módulo Seção
RFC 9457 Problem Details for HTTP APIs rest P18
RFC 8288 Web Linking (paginação) crud P19
RFC 6585 / RFC 7231 HTTP 429 + Retry-After security P20

Respostas de erro padronizadas (application/problem+json), headers Link para navegação de páginas, e respostas 429 Too Many Requests com Retry-After — tudo configurável e retrocompatível com o formato legado.

Visão Geral das Mudanças

Prioridade Funcionalidade Módulos
P1 Java 21 Records para DTOs crud, rest, configuration
P2 Sealed Classes para Filtros CRUD crud
P3 CDI 4.0 Lite Build-Compatible Extensions core, configuration
P4 Coleções Imutáveis no Módulo de Segurança security, crud
P5 Preparação para Virtual Threads configuration, script
P6 Melhorias na Criteria API do JPA 3.1 crud
P7 Soft Delete com @SoftDeletable crud
P8 Auditoria Automática crud
P9 Specification Pattern crud
P10 Operações em Batch crud
P11 PageResult Tipado crud
P12 Operadores de Comparação no FilterOp crud
P13 Cache de Consultas com Eventos CDI crud
P14 Módulo de Observabilidade observability
P15 Módulo OpenAPI openapi
P16 CI/CD com GitHub Actions ci/cd
P17 Testes de Integração entre Módulos integration-tests
P18 🌐 RFC 9457 — Problem Details rest
P19 🌐 RFC 8288 — Header Link para Paginação crud
P20 🌐 RFC 6585/7231 — Rate Limiting HTTP 429 security
P21 JWT Refresh Tokens e Blacklist security-jwt
P22 Identificação de Chave e Algoritmos JWT security-jwt
P23 Claims Customizados via ClaimsEnricher security-jwt
P24 Eventos de Segurança via CDI security
P25 @RequiredAnyRole e @RequiredAllPermissions security
P26 Configuração CORS via Properties security
P27 @CacheControl com Atributos Tipados rest
P28 Perfis de Configuração e @DefaultValue configuration
P29 Core API: Result Tipado e Conveniência core
P30 Security Token: Correções e Modernização security-token
P31 Módulo MCP (Model Context Protocol) mcp
P32 Headers REST seguros rest
P33 Limites de requisição CRUD crud
P34 Perfis avançados JWT security-jwt
P35 Segurança, SPIs e contratos operacionais core, configuration, crud, rest, security, jwt, hashcash, script, mcp

P1 — Java 21 Records para DTOs

SortModel

O SortModel agora é um record Java 21 imutável com validação no construtor compacto.

// Antes (classe mutável com boilerplate)
SortModel sort = new SortModel(CrudSort.ASC, "name");
sort.getType();  // getter tradicional
sort.getField();

// Depois (record imutável)
SortModel sort = new SortModel(CrudSort.ASC, "name");
sort.type();   // accessor do record
sort.field();

Validações automáticas:

// NullPointerException — tipo nulo
new SortModel(null, "name");

// NullPointerException — campo nulo
new SortModel(CrudSort.ASC, null);

// IllegalArgumentException — campo vazio ou em branco
new SortModel(CrudSort.ASC, "   ");

O equals() e hashCode() são gerados automaticamente pelo compilador com base nos componentes do record.

DemoiselleRestExceptionMessage

Mensagens de erro REST agora são records imutáveis com nomenclatura Java padrão:

// Criação de mensagem de erro
var msg = new DemoiselleRestExceptionMessage(
    "AUTH_FAILED",
    "Token expirado",
    "https://docs.example.com/errors/auth-failed"
);

// Acessores do record
msg.error();            // "AUTH_FAILED"
msg.errorDescription(); // "Token expirado"
msg.errorLink();        // pode ser null

Serialização JSON funciona nativamente com Jackson e JSON-B:

{
  "error": "AUTH_FAILED",
  "errorDescription": "Token expirado",
  "errorLink": "https://docs.example.com/errors/auth-failed"
}

Migração: Os campos foram renomeados de error_descriptionerrorDescription e error_linkerrorLink para seguir convenções Java.

ResultSet com List.copyOf()

O ResultSet agora usa cópias defensivas via List.copyOf():

ResultSet rs = new ResultSet();

// setContent(null) resulta em lista vazia, não NullPointerException
rs.setContent(null);
rs.getContent(); // → List.of() (lista vazia imutável)

// Cópia defensiva — modificações na lista original não afetam o ResultSet
List<String> original = new ArrayList<>(List.of("a", "b", "c"));
rs.setContent(original);
original.add("d");           // modifica a lista original
rs.getContent().size();       // → 3 (inalterado)

Records de Metadados no ConfigurationLoader

Dois records internos encapsulam metadados de configuração:

// Metadados de campo de configuração
record ConfigFieldMeta(
    String key,          // chave no arquivo de configuração
    Field field,         // campo refletido
    boolean ignored,     // @ConfigurationIgnore presente?
    boolean suppressLog  // @ConfigurationSuppressLogger presente?
) {}

// Metadados da fonte de configuração
record ConfigSourceMeta(
    ConfigurationType type,  // PROPERTIES, XML ou SYSTEM
    String resource,         // nome do arquivo
    String prefix            // prefixo das chaves
) {}

P2 — Sealed Classes para Filtros CRUD

FilterOp — Hierarquia Selada

A nova sealed interface FilterOp substitui a cascata de if-else no AbstractDAO por uma hierarquia type-safe com 7 variantes:

public sealed interface FilterOp {
    String key();

    record Equals(String key, String value)          implements FilterOp {}
    record Like(String key, String pattern)           implements FilterOp {}
    record IsNull(String key)                         implements FilterOp {}
    record IsTrue(String key)                         implements FilterOp {}
    record IsFalse(String key)                        implements FilterOp {}
    record EnumFilter(String key, String value, int ordinal) implements FilterOp {}
    record UUIDFilter(String key, UUID value)         implements FilterOp {}
}

Cada variante valida seus dados no construtor compactokey nunca é null, Like.pattern nunca é null, EnumFilter.ordinal é ≥ 0, etc.

Resolução Automática de Filtros

O AbstractDAO resolve automaticamente o tipo de filtro com base no valor recebido:

Valor FilterOp Resolvido
null ou "null" IsNull
*texto ou texto* Like
"true" / "isTrue" IsTrue
"false" / "isFalse" IsFalse
Campo enum EnumFilter
Campo UUID UUIDFilter
Qualquer outro Equals

Pattern Matching Exaustivo

O buildPredicate() usa switch com pattern matching garantido pelo compilador:

protected Predicate buildPredicate(FilterOp op, From<?, ?> from,
        CriteriaBuilder cb, CriteriaQuery<?> cq) {
    return switch (op) {
        case FilterOp.IsNull(var key)              -> cb.isNull(from.get(key));
        case FilterOp.Like(var key, var pattern)    -> buildLikePredicate(cb, cq, from, key, pattern);
        case FilterOp.IsTrue(var key)               -> cb.isTrue(from.get(key));
        case FilterOp.IsFalse(var key)              -> cb.isFalse(from.get(key));
        case FilterOp.EnumFilter(var key, _, var o) -> cb.equal(from.get(key), o);
        case FilterOp.UUIDFilter(var key, var uuid) -> cb.equal(from.get(key), uuid);
        case FilterOp.Equals(var key, var val)      -> cb.equal(from.get(key), val);
    };
    // Sem cláusula default — o compilador garante exaustividade!
}

Benefício: Se uma nova variante for adicionada à sealed interface, o código não compila até que o switch seja atualizado.


P3 — CDI 4.0 Lite Build-Compatible Extensions

MessageBundleBuildCompatibleExtension

A extensão CDI que descobre interfaces @MessageBundle foi migrada para a API Build-Compatible do CDI 4.0 Lite:

public class MessageBundleBuildCompatibleExtension
        implements BuildCompatibleExtension {

    @Discovery
    public void discovery(ScannedClasses scan) {
        // CDI 4.0 Lite descobre automaticamente
    }

    @Enhancement(types = Object.class,
                 withAnnotations = MessageBundle.class)
    public void collectMessageBundles(ClassInfo classInfo) {
        // Coleta interfaces @MessageBundle
    }

    @Synthesis
    public void registerBeans(SyntheticComponents syn) {
        // Registra beans sintéticos com proxy dinâmico
    }
}

Como usar @MessageBundle (sem mudanças para o desenvolvedor):

@MessageBundle
public interface AppMessages {

    @MessageTemplate("{welcome}")
    String welcome();

    @MessageTemplate("{greeting}")
    String greeting(String name);
}
# AppMessages.properties
welcome=Bem-vindo ao sistema
greeting=Olá, %s!
@Inject
@MessageBundle
AppMessages messages;

messages.welcome();           // "Bem-vindo ao sistema"
messages.greeting("João");    // "Olá, João!"

Compatibilidade: A extensão portável original (MessageBundleExtension) é mantida como fallback para containers que não suportam CDI Lite.

ConfigurationBuildCompatibleExtension

A descoberta de ConfigurationValueExtractor também foi migrada para Build-Compatible Extension:

public class ConfigurationBuildCompatibleExtension
        implements BuildCompatibleExtension {

    @Discovery
    public void discovery(ScannedClasses scan) { }

    @Enhancement(types = ConfigurationValueExtractor.class)
    public void collectExtractors(ClassInfo classInfo) {
        // Coleta implementações de ConfigurationValueExtractor
    }

    @Synthesis
    public void registerExtractorRegistry(SyntheticComponents syn) {
        // Registra bean sintético ApplicationScoped com o cache de extractors
    }
}

Benefícios das Build-Compatible Extensions:

  • Compatível com GraalVM native image (processamento em build-time)
  • Startup mais rápido em ambientes CDI Lite
  • API mais declarativa e menos propensa a erros

P4 — Coleções Imutáveis no Módulo de Segurança

DemoiselleUserImpl — Cópias Defensivas

Todos os getters de coleções no DemoiselleUserImpl agora retornam cópias defensivas verdadeiramente imutáveis:

@Inject
DemoiselleUser user;

// getRoles() retorna cópia independente via List.copyOf()
List<String> roles = user.getRoles();
// Modificações internas posteriores NÃO afetam esta cópia

// getPermissions() retorna deep copy
Map<String, List<String>> perms = user.getPermissions();
// Cada lista de valores também é copiada via List.copyOf()

// getParams() retorna cópia independente via Map.copyOf()
Map<String, String> params = user.getParams();

Diferença em relação à versão anterior:

// ANTES: Collections.unmodifiableList() — view mutável
// Se a lista interna mudasse, a view refletia a mudança
List<String> roles = user.getRoles(); // view
user.addRole("admin");
roles.contains("admin"); // true (!) — a view refletia a mudança

// DEPOIS: List.copyOf() — cópia defensiva
List<String> roles = user.getRoles(); // cópia
user.addRole("admin");
roles.contains("admin"); // false — a cópia é independente

Validação de null em addRole():

user.addRole(null); // → NullPointerException

P5 — Preparação para Virtual Threads

ConfigurationLoader — ReentrantReadWriteLock

O ConfigurationLoader substituiu synchronized por ReentrantReadWriteLock para compatibilidade com Virtual Threads (Project Loom):

private final ReadWriteLock rwLock = new ReentrantReadWriteLock();

public void load(final Object object, Class<?> baseClass) {
    // Leitura rápida — múltiplas threads leem simultaneamente
    rwLock.readLock().lock();
    try {
        if (isAlreadyLoaded(object)) return;
    } finally {
        rwLock.readLock().unlock();
    }

    // Escrita exclusiva — apenas uma thread por vez
    rwLock.writeLock().lock();
    try {
        // Double-checked locking
        if (!isAlreadyLoaded(object)) {
            processConfiguration(object, baseClass);
        }
    } finally {
        rwLock.writeLock().unlock();
    }
}

Benefícios:

  • Virtual threads não ficam pinned ao carrier thread durante espera
  • Múltiplas leituras simultâneas para configurações já carregadas
  • Escrita exclusiva apenas no primeiro carregamento
  • Recuperação automática após exceções (retry habilitado)

DynamicManagerCache — Campos de Instância

O DynamicManagerCache eliminou campos static mutáveis em favor de campos de instância gerenciados pelo CDI:

@ApplicationScoped
public class DynamicManagerCache implements Serializable {

    // ANTES: static Map (compartilhado globalmente, não GC-friendly)
    // DEPOIS: campos de instância (ciclo de vida gerenciado pelo CDI)
    private final Map<String, ConcurrentHashMap<String, Object>> scriptCache =
        new ConcurrentHashMap<>();
    private final Map<String, Object> engineList =
        new ConcurrentHashMap<>();

    public Map<String, ConcurrentHashMap<String, Object>> getScriptCache() {
        return scriptCache;
    }

    public Map<String, Object> getEngineList() {
        return engineList;
    }
}
// No DynamicManager — injeção via CDI
@Inject
private DynamicManagerCache cache;

// Uso via getters em vez de acesso estático
cache.getEngineList().put(engineName, engine);
cache.getScriptCache().put(engineName, new ConcurrentHashMap<>());

P6 — Melhorias na Criteria API do JPA 3.1

mergeHalf() com CriteriaUpdate

O mergeHalf() foi refatorado de JPQL via StringBuilder para CriteriaUpdate type-safe:

@Override
public T mergeHalf(I id, T entity) {
    CriteriaBuilder cb = getEntityManager().getCriteriaBuilder();
    CriteriaUpdate<T> update = cb.createCriteriaUpdate(entityClass);
    Root<T> root = update.from(entityClass);

    boolean hasUpdates = false;

    for (final Field field : getAllFields(entityClass)) {
        // @ManyToOne sempre incluído
        if (!field.isAnnotationPresent(ManyToOne.class)) {
            final Column column = field.getAnnotation(Column.class);
            // Sem @Column ou @Column(updatable=false) → pular
            if (column == null || !column.updatable()) {
                continue;
            }
        }
        field.setAccessible(true);
        final Object value = field.get(entity);
        if (value != null) {
            update.set(root.get(field.getName()), value);
            hasUpdates = true;
        }
    }

    if (hasUpdates) {
        String idName = CrudUtilHelper.getMethodAnnotatedWithID(entityClass);
        update.where(cb.equal(root.get(idName), id));
        getEntityManager().createQuery(update).executeUpdate();
    }

    return entity;
}

Regras de inclusão de campos:

Anotação Valor Incluído no UPDATE?
@Column(updatable=true) não-null
@Column(updatable=true) null
@Column(updatable=false) qualquer
@ManyToOne não-null
@ManyToOne null
Sem anotação qualquer

Benefício: Sem risco de SQL injection via concatenação de strings. Erros de nome de campo detectados em tempo de compilação com metamodel.

EntityGraph no AbstractDAO.find()

Subclasses do AbstractDAO agora podem controlar a estratégia de fetch via EntityGraph:

public class PedidoDAO extends AbstractDAO<Pedido, Long> {

    @Override
    protected EntityGraph<Pedido> getEntityGraph() {
        EntityGraph<Pedido> graph = getEntityManager()
            .createEntityGraph(Pedido.class);
        graph.addAttributeNodes("itens", "cliente");
        return graph;
    }
}

Quando getEntityGraph() retorna não-null, o hint jakarta.persistence.fetchgraph é aplicado automaticamente na TypedQuery:

TypedQuery<T> query = getEntityManager().createQuery(criteriaQuery);

EntityGraph<T> graph = getEntityGraph();
if (graph != null) {
    query.setHint("jakarta.persistence.fetchgraph", graph);
}

Comportamento padrão: getEntityGraph() retorna null — nenhum hint é aplicado e o comportamento existente de paginação, ordenação e filtragem é mantido.


P7 — Soft Delete com @SoftDeletable

Exclusão lógica declarativa via anotação. Em vez de DELETE físico, o framework executa um UPDATE marcando o registro como excluído.

Configuração na Entidade

@Entity
@SoftDeletable(field = "deletedAt")
public class Produto {

    @Id @GeneratedValue
    private Long id;

    private String nome;

    private LocalDateTime deletedAt; // campo de soft delete

    // getters e setters
}

Tipos Suportados

// LocalDateTime (padrão)
@SoftDeletable(field = "deletedAt")

// Boolean
@SoftDeletable(field = "deleted", type = Boolean.class)

// Instant
@SoftDeletable(field = "deletedInstant", type = Instant.class)

Comportamento Automático no AbstractDAO

// remove() executa UPDATE em vez de DELETE
dao.remove(42L);
// SQL gerado: UPDATE produto SET deleted_at = '2026-04-01T10:30:00' WHERE id = 42

// find() exclui registros soft-deleted automaticamente
Result result = dao.find();
// SQL gerado: SELECT ... FROM produto WHERE deleted_at IS NULL

// find(id) retorna null para registros soft-deleted
Produto p = dao.find(42L); // → null (registro marcado como excluído)

// count() exclui registros soft-deleted
Long total = dao.count();
// SQL gerado: SELECT COUNT(*) FROM produto WHERE deleted_at IS NULL

// findIncludingDeleted() retorna TODOS os registros
Result todos = dao.findIncludingDeleted();
// SQL gerado: SELECT ... FROM produto (sem filtro de soft delete)

Filtro para Boolean

Quando o tipo é Boolean, o filtro é WHERE deleted = false OR deleted IS NULL:

@SoftDeletable(field = "deleted", type = Boolean.class)
public class Tarefa {
    private Boolean deleted;
}

// remove() → UPDATE tarefa SET deleted = true WHERE id = ?
// find()   → SELECT ... WHERE (deleted = false OR deleted IS NULL)

Validação na Inicialização

// Campo inexistente → DemoiselleCrudException na inicialização do DAO
@SoftDeletable(field = "campoInexistente")
public class Invalida { }

// Tipo não suportado → DemoiselleCrudException
@SoftDeletable(field = "nome", type = String.class)
public class TipoInvalido { }

P8 — Auditoria Automática

Preenchimento automático de campos de auditoria via JPA EntityListener, sem código manual em cada entidade.

Anotações Disponíveis

Anotação Evento Valor
@CreatedAt @PrePersist LocalDateTime.now()
@CreatedBy @PrePersist DemoiselleUser.getIdentity()
@UpdatedAt @PreUpdate LocalDateTime.now()
@UpdatedBy @PreUpdate DemoiselleUser.getIdentity()

Configuração na Entidade

@Entity
@EntityListeners(AuditEntityListener.class)
public class Pedido {

    @Id @GeneratedValue
    private Long id;

    private String descricao;

    @CreatedAt
    private LocalDateTime criadoEm;

    @CreatedBy
    private String criadoPor;

    @UpdatedAt
    private LocalDateTime atualizadoEm;

    @UpdatedBy
    private String atualizadoPor;

    // getters e setters
}

Comportamento

// Ao persistir: preenche apenas @CreatedAt e @CreatedBy
entityManager.persist(pedido);
// pedido.criadoEm    → 2026-04-01T10:30:00
// pedido.criadoPor   → "admin"
// pedido.atualizadoEm → null
// pedido.atualizadoPor → null

// Ao atualizar: preenche apenas @UpdatedAt e @UpdatedBy
entityManager.merge(pedido);
// pedido.criadoEm    → (inalterado)
// pedido.criadoPor   → (inalterado)
// pedido.atualizadoEm → 2026-04-01T11:00:00
// pedido.atualizadoPor → "editor"

Fallback sem Contexto de Usuário

Quando DemoiselleUser não está disponível (ex.: operações em batch sem requisição HTTP), o valor de @CreatedBy/@UpdatedBy é "system".


P9 — Specification Pattern

Composição declarativa de consultas JPA complexas sem escrever CriteriaQuery manualmente.

Interface Funcional

@FunctionalInterface
public interface Specification<T> {
    Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb);

    // Métodos default para composição
    default Specification<T> and(Specification<T> other) { ... }
    default Specification<T> or(Specification<T> other)  { ... }
    default Specification<T> not()                        { ... }
}

Criando Specifications

// Specification simples
Specification<Produto> precoMaior100 = (root, query, cb) ->
    cb.greaterThan(root.get("preco"), 100.0);

Specification<Produto> categoriaEletronicos = (root, query, cb) ->
    cb.equal(root.get("categoria"), "ELETRONICOS");

Specification<Produto> emEstoque = (root, query, cb) ->
    cb.greaterThan(root.get("estoque"), 0);

Composição

// AND: produtos eletrônicos com preço > 100
Specification<Produto> filtro = categoriaEletronicos.and(precoMaior100);

// OR: eletrônicos OU preço > 100
Specification<Produto> filtroOu = categoriaEletronicos.or(precoMaior100);

// NOT: produtos que NÃO são eletrônicos
Specification<Produto> naoEletronicos = categoriaEletronicos.not();

// Composição complexa: eletrônicos em estoque OU preço > 100
Specification<Produto> complexo = categoriaEletronicos.and(emEstoque)
                                                       .or(precoMaior100);

Uso no AbstractDAO

// find(spec) combina Specification com filtros do DemoiselleRequestContext
Result result = dao.find(precoMaior100.and(emEstoque));

// Paginação é aplicada automaticamente quando habilitada
// Retorna PageResult com metadados quando paginação está ativa

// find(null) equivale a find() padrão
Result result = dao.find(null); // mesmo que dao.find()

P10 — Operações em Batch

Processamento eficiente de grandes volumes com flush/clear automático para evitar estouro de memória.

Configuração

# demoiselle.properties
demoiselle.crud.batch.size=100

Valor padrão: 50 registros por batch. O valor deve ser maior que zero; configurações 0 ou negativas falham imediatamente com uma mensagem clara, antes de iniciar a operação.

persistAll

List<Produto> produtos = List.of(
    new Produto("Notebook", 3500.0),
    new Produto("Mouse", 89.90),
    new Produto("Teclado", 199.90)
);

// Persiste todos com flush/clear a cada N registros
List<Produto> persistidos = dao.persistAll(produtos);
// persistidos.size() == 3

Em caso de erro, a exceção inclui o índice da entidade que falhou:

try {
    dao.persistAll(entidades);
} catch (DemoiselleCrudException e) {
    // "Erro ao persistir entidade no índice 42"
    e.getMessage();
    e.getCause(); // PersistenceException original
}

removeAll

List<Long> ids = List.of(1L, 2L, 3L, 4L, 5L);

// Remove todos (respeita @SoftDeletable quando presente)
int removidos = dao.removeAll(ids);
// removidos == 5

updateAll

// Atualiza todos os produtos da categoria "ELETRONICOS" com desconto
Specification<Produto> eletronicos = (root, query, cb) ->
    cb.equal(root.get("categoria"), "ELETRONICOS");

Map<String, Object> updates = Map.of(
    "desconto", 0.15,
    "emPromocao", true
);

int atualizados = dao.updateAll(eletronicos, updates);
// SQL: UPDATE produto SET desconto = 0.15, em_promocao = true
//      WHERE categoria = 'ELETRONICOS'

P11 — PageResult Tipado

Record imutável com metadados completos de paginação, eliminando cálculos manuais no frontend.

Estrutura

public record PageResult<T>(
    List<T> content,       // conteúdo da página
    long totalElements,    // total de elementos em todas as páginas
    int totalPages,        // total de páginas
    int currentPage,       // página atual (0-indexed)
    int pageSize,          // tamanho da página
    boolean hasNext,       // existe próxima página?
    boolean hasPrevious    // existe página anterior?
) implements Result { }

Uso Automático

O AbstractDAO.find() retorna PageResult automaticamente quando a paginação está habilitada:

// Com paginação habilitada → PageResult
Result result = dao.find();
if (result instanceof PageResult<?> page) {
    page.totalElements(); // 150
    page.totalPages();    // 15
    page.currentPage();   // 0
    page.pageSize();      // 10
    page.hasNext();       // true
    page.hasPrevious();   // false
    page.content();       // List<Produto> (10 itens)
}

// Sem paginação → ResultSet (comportamento anterior mantido)

Factory Method

// Criação manual com cálculos automáticos de metadados
PageResult<Produto> page = PageResult.of(
    produtos,        // conteúdo
    150L,            // totalElements
    20,              // offset
    10               // pageSize
);
// totalPages = 15, currentPage = 2, hasNext = true, hasPrevious = true

Headers HTTP

Quando a resposta contém PageResult, o CrudFilter inclui headers automaticamente:

HTTP/1.1 200 OK
X-Total-Count: 150
X-Total-Pages: 15
X-Current-Page: 0
X-Page-Size: 10
X-Has-Next: true
X-Has-Previous: false
Content-Range: ...
Access-Control-Expose-Headers: ..., X-Total-Count, X-Total-Pages, ...

P12 — Operadores de Comparação no FilterOp

6 novos operadores de comparação via query string, sem necessidade de endpoints customizados.

Novos Operadores

Prefixo Operador Exemplo Query String FilterOp
gt: Maior que ?preco=gt:100 GreaterThan
lt: Menor que ?preco=lt:50 LessThan
gte: Maior ou igual ?idade=gte:18 GreaterThanOrEqual
lte: Menor ou igual ?estoque=lte:10 LessThanOrEqual
between: Entre dois valores ?preco=between:10,100 Between
in: Lista de valores ?status=in:ATIVO,PENDENTE In

Exemplos de Uso via API REST

GET /api/produtos?preco=gt:100
GET /api/produtos?preco=between:50,200
GET /api/produtos?categoria=in:ELETRONICOS,INFORMATICA,GAMES
GET /api/produtos?estoque=lte:5
GET /api/produtos?dataCriacao=gte:2026-01-01

Precedência

Prefixos de operador têm precedência sobre filtros existentes. Um valor como gt:*texto* é interpretado como GreaterThan (não Like):

?campo=gt:true    → GreaterThan (não IsTrue)
?campo=lt:*abc*   → LessThan (não Like)
?campo=in:null    → In com valor "null" (não IsNull)

Validação

# between: requer exatamente 2 valores
?preco=between:10        → IllegalArgumentException
?preco=between:10,20,30  → IllegalArgumentException
?preco=between:10,20     → OK (Between com lower=10, upper=20)

Hierarquia Completa do FilterOp (13 variantes)

public sealed interface FilterOp {
    // Originais (7)
    record Equals(String key, String value)                    implements FilterOp {}
    record Like(String key, String pattern)                    implements FilterOp {}
    record IsNull(String key)                                  implements FilterOp {}
    record IsTrue(String key)                                  implements FilterOp {}
    record IsFalse(String key)                                 implements FilterOp {}
    record EnumFilter(String key, String value, int ordinal)   implements FilterOp {}
    record UUIDFilter(String key, UUID value)                  implements FilterOp {}

    // Novos operadores de comparação (6)
    record GreaterThan(String key, String value)               implements FilterOp {}
    record LessThan(String key, String value)                  implements FilterOp {}
    record GreaterThanOrEqual(String key, String value)        implements FilterOp {}
    record LessThanOrEqual(String key, String value)           implements FilterOp {}
    record Between(String key, String lower, String upper)     implements FilterOp {}
    record In(String key, List<String> values)                 implements FilterOp {}
}

P13 — Cache de Consultas com Eventos CDI

Cache automático de resultados de consultas com invalidação reativa via eventos CDI.

Habilitando Cache em um Método

public class ProdutoREST extends AbstractREST<Produto, Long> {

    @GET
    @Cacheable(ttl = 60) // cache por 60 segundos
    public Result find() {
        return super.find();
    }

    @GET
    @Path("/destaque")
    @Cacheable // TTL padrão: 300 segundos (5 minutos)
    public Result findDestaques() {
        // consulta customizada
    }
}

Como Funciona

  1. Requisição GET chega ao endpoint @Cacheable
  2. CacheInterceptor verifica se existe resultado em cache para a chave entityClass:method:paramsHash
  3. Cache hit → retorna resultado imediatamente (header X-Cache: HIT)
  4. Cache miss → executa o método, armazena resultado no cache (header X-Cache: MISS)

Invalidação Automática

Operações de escrita no AbstractDAO disparam EntityModifiedEvent automaticamente:

// persist() → EntityModifiedEvent(Produto.class, PERSIST, entity)
// mergeFull() / mergeHalf() → EntityModifiedEvent(Produto.class, MERGE, entity)
// remove() → EntityModifiedEvent(Produto.class, REMOVE, id)

O CacheInvalidationListener observa esses eventos e invalida todas as entradas de cache da classe afetada:

// Ao persistir um Produto, TODAS as consultas cacheadas de Produto são invalidadas
// Consultas cacheadas de outras entidades (Pedido, Cliente) permanecem intactas

Em recursos que estendem AbstractREST<Produto, ...>, o CrudFilter resolve Produto automaticamente. Em outros beans CDI, declare a entidade para que a chave criada pelo interceptor use o mesmo namespace do evento:

@Cacheable(entityClass = Produto.class, ttl = 60)
public List<Produto> consultarDestaques() {
    // ...
}

Resultados null não são armazenados. A anotação também pode ser aplicada à classe; a configuração do método tem precedência.

Headers de Cache na Resposta

# Cache miss (primeira requisição)
HTTP/1.1 200 OK
X-Cache: MISS

# Cache hit (requisições subsequentes dentro do TTL)
HTTP/1.1 200 OK
X-Cache: HIT

Arquitetura do Cache

┌─────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  CrudFilter │────▶│  CacheInterceptor │────▶│ QueryCacheStore  │
│  (JAX-RS)   │     │  (CDI Interceptor)│     │ (ConcurrentMap)  │
└─────────────┘     └──────────────────┘     └─────────────────┘
                                                       ▲
                                                       │ invalidate
                                              ┌────────┴────────┐
                                              │ CacheInvalidation│
                                              │    Listener      │
                                              └────────┬────────┘
                                                       │ @Observes
                                              ┌────────┴────────┐
                                              │EntityModifiedEvent│
                                              └────────┬────────┘
                                                       │ fire()
                                              ┌────────┴────────┐
                                              │   AbstractDAO    │
                                              │ persist/merge/   │
                                              │    remove        │
                                              └─────────────────┘

P14 — Módulo de Observabilidade (demoiselle-observability)

Módulo transversal que fornece métricas, health checks e tracing distribuído para os demais módulos do framework. Todas as dependências externas (MicroProfile Metrics, MicroProfile Health, OpenTelemetry) são opcionais — quando ausentes, o módulo degrada graciosamente para implementações noop sem erro.

Dependência Maven

<dependency>
    <groupId>org.demoiselle.jee</groupId>
    <artifactId>demoiselle-observability</artifactId>
</dependency>

@Counted — Métricas via CDI Interceptor

Incrementa automaticamente um contador MicroProfile Metrics a cada invocação do método anotado.

import org.demoiselle.jee.observability.annotation.Counted;

@ApplicationScoped
public class TokenService {

    @Counted("demoiselle.jwt.tokens.issued")
    public String issueToken(DemoiselleUser user) {
        // lógica de emissão de token
        return jwt;
    }

    @Counted // nome automático: "TokenService.validateToken"
    public DemoiselleUser validateToken(String jwt) {
        // lógica de validação
        return user;
    }
}

Contadores pré-definidos do framework:

Módulo Operação Contador
security-jwt Token emitido demoiselle.jwt.tokens.issued
security-jwt Token validado demoiselle.jwt.tokens.validated
rest Rate limit rejeitado demoiselle.rest.ratelimit.rejected
configuration Configuração carregada demoiselle.configuration.loaded
script Script executado demoiselle.script.executed

Quando value() está vazio, o nome é gerado automaticamente no formato Classe.metodo.

@Traced — Tracing OpenTelemetry via CDI Interceptor

Cria spans OpenTelemetry automaticamente com atributos demoiselle.module e demoiselle.operation.

import org.demoiselle.jee.observability.annotation.Traced;

@ApplicationScoped
public class PedidoService {

    @Traced(module = "pedido", operation = "criar")
    public Pedido criarPedido(PedidoDTO dto) {
        // lógica de criação
        return pedido;
    }

    @Traced // module = "PedidoService", operation = "buscarPorId"
    public Pedido buscarPorId(Long id) {
        return pedido;
    }
}

Quando module() ou operation() estão vazios, os valores são derivados do nome da classe e do método.

MetricsAdapter — Abstração para Degradação Graceful

// Interface
public interface MetricsAdapter {
    void increment(String counterName);
    long getCount(String counterName);
}

// Quando MicroProfile Metrics está no classpath:
//   → MicroProfileMetricsAdapter (delega para MetricRegistry)

// Quando MicroProfile Metrics NÃO está no classpath:
//   → NoopMetricsAdapter (increment() não faz nada, getCount() retorna 0)

TracingAdapter — Abstração para Degradação Graceful

public interface TracingAdapter {
    <T> T executeInSpan(String module, String operation, SpanCallable<T> callable) throws Exception;

    @FunctionalInterface
    interface SpanCallable<T> {
        T call() throws Exception;
    }
}

// Quando OpenTelemetry está no classpath:
//   → OpenTelemetryTracingAdapter (cria spans com atributos)

// Quando OpenTelemetry NÃO está no classpath:
//   → NoopTracingAdapter (executa callable diretamente)

Health Checks — MicroProfile Health

O módulo registra automaticamente health checks quando MicroProfile Health está no classpath:

GET /health/live
{
  "status": "UP",
  "checks": [
    { "name": "demoiselle-cdi", "status": "UP" }
  ]
}

GET /health/ready
{
  "status": "UP",
  "checks": [
    { "name": "demoiselle-configuration", "status": "UP", "data": { "module": "demoiselle-configuration" } },
    { "name": "demoiselle-security-jwt-keys", "status": "UP", "data": { "type": "master", "publicKeyConfigured": true } }
  ]
}

Health checks registrados:

Nome Tipo Verifica
demoiselle-cdi Liveness CDI container ativo
demoiselle-configuration Readiness Configuração carregada
demoiselle-security-jwt-keys Readiness Chaves JWT disponíveis (quando demoiselle-security-jwt no classpath)

ObservabilityExtension — Detecção Automática de APIs

A CDI Extension detecta automaticamente quais APIs estão no classpath e registra os beans apropriados:

[INFO] MicroProfile Metrics detected — registering MicroProfileMetricsAdapter
[INFO] MicroProfile Health detected — registering HealthCheckProducer
[INFO] OpenTelemetry não disponível — tracing desativado

Nenhuma configuração manual é necessária. Basta adicionar a dependência da API desejada ao pom.xml:

<!-- Para métricas -->
<dependency>
    <groupId>org.eclipse.microprofile.metrics</groupId>
    <artifactId>microprofile-metrics-api</artifactId>
</dependency>

<!-- Para health checks -->
<dependency>
    <groupId>org.eclipse.microprofile.health</groupId>
    <artifactId>microprofile-health-api</artifactId>
</dependency>

<!-- Para tracing -->
<dependency>
    <groupId>io.opentelemetry</groupId>
    <artifactId>opentelemetry-api</artifactId>
</dependency>

P15 — Módulo OpenAPI (demoiselle-openapi)

Geração automática de documentação OpenAPI para endpoints do framework via MicroProfile OpenAPI.

Dependência Maven

<dependency>
    <groupId>org.demoiselle.jee</groupId>
    <artifactId>demoiselle-openapi</artifactId>
</dependency>

OpenAPIContributor — Interface para Contribuições Modulares

Cada módulo do framework pode contribuir definições OpenAPI parciais implementando esta interface:

import org.demoiselle.jee.openapi.OpenAPIContributor;
import org.eclipse.microprofile.openapi.OASFactory;
import org.eclipse.microprofile.openapi.models.OpenAPI;

@ApplicationScoped
public class MeuModuloOpenAPIContributor implements OpenAPIContributor {

    @Override
    public OpenAPI contribute() {
        OpenAPI partial = OASFactory.createOpenAPI();
        partial.paths(OASFactory.createPaths()
            .addPathItem("/api/meu-recurso", OASFactory.createPathItem()
                .GET(OASFactory.createOperation()
                    .summary("Lista recursos")
                    .operationId("listarRecursos"))));
        return partial;
    }
}

DemoiselleOASModelReader — Agregação Automática

O DemoiselleOASModelReader descobre todos os OpenAPIContributor via CDI e agrega suas contribuições:

  • Paths de múltiplos contributors são mesclados no documento final
  • Em caso de sobreposição, o primeiro contributor processado prevalece (first-wins)
  • Se um contributor lançar exceção, o erro é logado e os demais continuam normalmente

Configuração de Ativação

# demoiselle.properties
# Desativar documentação OpenAPI automática (default: true)
demoiselle.openapi.enabled=false

Quando desativado, DemoiselleOASModelReader.buildModel() retorna um documento OpenAPI vazio.


P16 — CI/CD com GitHub Actions

O pipeline de CI/CD usa Java 21, o mesmo baseline exigido pelo build Maven.

Build Java 21 e supply chain

# .github/workflows/ci.yml
permissions:
  contents: read

steps:
  - uses: actions/checkout@v4
    with:
      persist-credentials: false
  - uses: actions/setup-java@v4
    with:
      java-version: '21'
      distribution: temurin
      cache: maven
  - run: mvn clean verify -B
  • O Maven Enforcer exige Java 21+ e Maven 3.9+ já na fase validate.
  • Plugins Maven usados pelo reactor têm versões explícitas.
  • O CycloneDX gera target/bom.json e target/bom.xml agregados na fase package.
  • O checkout não mantém credenciais e o workflow recebe somente contents: read.
  • Relatórios JaCoCo são publicados como artefatos do build Java 21.

Relatórios de Cobertura em PRs

Em pull requests, um job separado gera relatório agregado de cobertura e posta um resumo no PR:

📊 JaCoCo Coverage Summary

| Module                  | Instruction | Branch | Line  | Method |
|-------------------------|------------|--------|-------|--------|
| demoiselle-core         | 85.2%      | 72.1%  | 83.4% | 90.1%  |
| demoiselle-configuration| 78.5%      | 65.3%  | 76.2% | 85.7%  |
| demoiselle-security     | 91.0%      | 80.4%  | 89.1% | 93.2%  |

Limiar de Cobertura Configurável

<!-- pom.xml raiz -->
<properties>
    <jacoco.minimum.instruction.coverage>0.05</jacoco.minimum.instruction.coverage>
    <jacoco.minimum.branch.coverage>0.01</jacoco.minimum.branch.coverage>
</properties>

A regra é avaliada por bundle Maven e bloqueia regressões abaixo dos gates iniciais de 5% de instruções e 1% de branches. Os limites devem crescer de forma gradual conforme a cobertura dos módulos aumenta.


P17 — Módulo de Testes de Integração (demoiselle-integration-tests)

Módulo dedicado a testes de integração que validam fluxos completos entre múltiplos módulos do framework.

Estrutura

  • Executado na fase verify do Maven via maven-failsafe-plugin
  • Testes unitários (surefire) são desabilitados neste módulo
  • Módulos opcionais são tratados com @EnabledIf do JUnit 5

Fluxo Configuração → Segurança → REST

@EnabledIf("isSecurityJwtAvailable")
class ConfigSecurityRestIT {

    @Test
    void fullFlow_configLoadAndTokenIssueAndValidate() {
        // 1. Carregar configuração de segurança
        // 2. Emitir token JWT com claims
        // 3. Validar token em novo TokenManager
        // 4. Verificar que claims são preservados
    }

    @Test
    void requiredRole_acceptsValidTokenWithCorrectRole() {
        // Token com role "admin" → interceptor permite execução
    }

    @Test
    void requiredRole_rejectsTokenWithWrongRole() {
        // Token com role "viewer" → interceptor rejeita com 403
    }

    @Test
    void expiredToken_isRejectedWithUnauthorized() {
        // Token expirado → interceptor rejeita com 401
    }

    @Test
    void corsFilter_appliesConfiguredHeaders() {
        // Filtro CORS aplica headers configurados
    }
}

Fluxo Configuração → Script

@EnabledIf("isScriptAvailable")
class ConfigScriptIT {

    @Test
    void loadEngineAndExecuteScriptWithParameters() {
        // 1. Carregar engine Groovy
        // 2. Cachear script com parâmetros
        // 3. Executar e verificar resultado
    }

    @Test
    void fullFlow_loadCacheUpdateAndReExecute() {
        // Carregar → cachear → atualizar → re-executar
    }
}

Tratamento de Módulos Opcionais

@EnabledIf("isSecurityJwtAvailable")
class SecurityJwtIntegrationIT {

    static boolean isSecurityJwtAvailable() {
        try {
            Class.forName("org.demoiselle.jee.security.jwt.impl.TokenManagerImpl");
            return true;
        } catch (ClassNotFoundException e) {
            return false;
        }
    }
}

Quando o módulo não está no classpath, os testes são ignorados automaticamente (não falham).


P18 — Conformidade RFC 9457 — Problem Details for HTTP APIs

🌐 Padrão IETFRFC 9457 — Respostas de erro padronizadas com application/problem+json

Respostas de erro padronizadas conforme RFC 9457 com media type application/problem+json.

Ativação

# demoiselle.properties
demoiselle.rest.errorFormat=rfc9457

Valor padrão: "legacy" — formato anterior mantido para retrocompatibilidade. Qualquer valor diferente de "rfc9457" é normalizado para "legacy".

Formato da Resposta

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Recurso /api/items/42 não encontrado",
  "instance": "/api/items/42"
}

Campos null são omitidos do JSON. Campos de extensão aparecem no nível raiz:

{
  "type": "urn:demoiselle:validation-error",
  "title": "Validation Failed",
  "status": 412,
  "instance": "/api/users",
  "violations": [
    {"field": "User.nome", "message": "não pode ser vazio"}
  ]
}

Mapeamento de Exceções

Exceção Status Title
ConstraintViolationException 412 Validation Failed
SQLException 500 Database Error
DemoiselleRestException statusCode da exceção Primeira mensagem
InvalidFormatException 400 Malformed Input
ClientErrorException status do cliente HTTP Error
Exceção genérica 500 Internal Server Error

About:blank Automático (RFC 9457 §4.2)

Quando type é "about:blank" e title é null, o framework preenche title automaticamente com a frase-razão HTTP:

ProblemDetail pd = new ProblemDetail();
pd.setStatus(404);
pd.applyAboutBlankDefaults();
pd.getTitle(); // → "Not Found"

Mapeamento DemoiselleRestExceptionMessage → ProblemDetail

DemoiselleRestExceptionMessage          ProblemDetail
├── error ─────────────────────────→ title
├── errorDescription ──────────────→ detail
└── errorLink (não vazio) ─────────→ type

Quando múltiplas mensagens estão presentes, todas são incluídas como extensão "messages".

Controle de Detalhes

Detalhes internos são omitidos por padrão para não expor mensagens de banco, stack traces ou implementação. Só habilite a opção durante diagnóstico em ambiente controlado:

# Opt-in temporário; não recomendado em produção
demoiselle.rest.showErrorDetails=true

P32 — Headers REST seguros

O módulo REST adiciona por padrão os headers abaixo, sem sobrescrever valores já definidos pela aplicação:

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: no-referrer
Permissions-Policy: camera=(), microphone=(), geolocation=()

HSTS e CSP não são enviados automaticamente porque dependem do uso efetivo de HTTPS e da política de conteúdo da aplicação. Para substituir o mapa ou desligar o filtro:

demoiselle.rest.securityHeadersEnabled=true
demoiselle.rest.securityHeaders.X-Content-Type-Options=nosniff
demoiselle.rest.securityHeaders.X-Frame-Options=SAMEORIGIN

Por redução de fingerprinting, Demoiselle-Version fica oculto por padrão. A compatibilidade pode ser reativada explicitamente:

demoiselle.rest.exposeFrameworkVersion=true

🌐 Padrão IETFRFC 8288 — Web Linking para navegação de páginas via header Link

Headers Link padronizados conforme RFC 8288 nas respostas paginadas, complementando os headers customizados existentes.

Resposta Paginada

HTTP/1.1 200 OK
X-Total-Count: 150
X-Total-Pages: 15
X-Current-Page: 1
X-Page-Size: 10
X-Has-Next: true
X-Has-Previous: true
Link: </api/resource?range=0-9>; rel="first",
      </api/resource?range=0-9>; rel="prev",
      </api/resource?range=20-29>; rel="next",
      </api/resource?range=140-149>; rel="last"
Access-Control-Expose-Headers: ..., Link

Regras de Geração

Relação Condição
rel="first" Sempre presente quando totalPages > 1
rel="last" Sempre presente quando totalPages > 1
rel="next" Presente quando hasNext == true
rel="prev" Presente quando hasPrevious == true

Quando totalPages <= 1, nenhum header Link é emitido.

LinkHeaderBuilder

Classe utilitária para gerar headers Link a partir de PageResult:

String link = LinkHeaderBuilder.build("/api/resource?filter=active", pageResult);
// Preserva query parameters existentes:
// </api/resource?filter=active&range=10-19>; rel="next", ...

Retrocompatibilidade

Todos os headers customizados (X-Total-Count, X-Total-Pages, X-Current-Page, X-Page-Size, X-Has-Next, X-Has-Previous) continuam presentes. O header Link é adicionado em complemento.


P33 — Limites de requisição CRUD

O CRUD aplica tetos globais antes de construir consultas, reduzindo risco de consumo excessivo de memória e banco. Os defaults são:

demoiselle.crud.pagination.maxPagination=100
demoiselle.crud.limits.maxFilters=20
demoiselle.crud.limits.maxFilterValues=100
demoiselle.crud.limits.maxFilterValueLength=1024
demoiselle.crud.limits.maxSortFields=10

O tamanho efetivo da página é o menor valor entre o default global ou @Search.quantityPerPage e maxPagination. Requisições que excedem os demais limites são rejeitadas como entrada inválida antes da validação dos campos. Valores nulos ou não positivos nas propriedades de limits retornam aos defaults seguros; configure valores positivos para ampliar ou reduzir os tetos.


P20 — RFC 6585/7231 — Rate Limiting com HTTP 429

🌐 Padrão IETFRFC 6585 / RFC 7231 — Respostas 429 Too Many Requests com header Retry-After

O RateLimitInterceptor agora retorna respostas HTTP 429 com header Retry-After conforme as RFCs 6585 e 7231, em vez de lançar exceções genéricas.

Resposta 429 (formato RFC 9457)

HTTP/1.1 429 Too Many Requests
Retry-After: 5
Content-Type: application/problem+json

{
  "type": "urn:demoiselle:rate-limit-exceeded",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "Retry after 5 seconds"
}

Resposta 429 (formato legado)

HTTP/1.1 429 Too Many Requests
Retry-After: 5
Content-Type: application/json

{
  "error": "Rate limit exceeded. Retry after 5 seconds"
}

O formato é selecionado automaticamente com base na configuração errorFormat. Quando o módulo REST não está presente, o formato legado é usado como fallback.

Retry-After

O valor do header Retry-After é calculado pelo SlidingWindowCounter com base no registro mais antigo na janela deslizante. O valor é sempre >= 1 e <= windowSeconds.


P21 — JWT Refresh Tokens e Blacklist

Refresh Token

O RefreshTokenManager gera pares access token / refresh token:

@Inject
RefreshTokenManager refreshTokenManager;

// Gerar par de tokens
TokenPair pair = refreshTokenManager.issueTokenPair(user);
pair.accessToken();  // JWT de curta duração
pair.refreshToken(); // JWT de longa duração (apenas sub, jti, exp)

// Renovar access token
String newAccessToken = refreshTokenManager.refresh(pair.refreshToken());
# demoiselle.properties
demoiselle.security.jwt.refreshTokenTtlMilliseconds=86400000  # 24h (padrão)

Token Blacklist

Revogação de tokens antes da expiração via JTI:

// removeUser() automaticamente adiciona o JTI à blacklist
tokenManager.removeUser(user);

// Tokens revogados são rejeitados na validação
tokenManager.getUser(); // → DemoiselleSecurityException (401)

A blacklist é @ApplicationScoped e remove automaticamente entradas expiradas.


P22 — Identificação de Chave e Algoritmos JWT

Identificador da Chave Ativa

# demoiselle.properties
# A chave pública/privada continua sendo configurada pelas propriedades do módulo.
demoiselle.security.jwt.activeKeyId=key-2024
demoiselle.security.jwt.privateKey=...
demoiselle.security.jwt.publicKey=...

Novos tokens recebem activeKeyId no header kid. Na validação, um kid explícito e desconhecido é rejeitado com HTTP 401; a chave fallback só pode ser usada quando o token omite kid (compatibilidade) ou informa o identificador ativo. Um provedor configurável de múltiplas chaves é uma evolução planejada, não devendo ser simulado com propriedades aninhadas não suportadas.

Algoritmos Permitidos

# Opcional; RS256 é o default seguro
demoiselle.security.jwt.allowedAlgorithms=RS256,RS384,RS512

A lista nunca fica aberta: sem configuração explícita, somente RS256 é permitido. Tokens sem algoritmo ou com algoritmo fora da allowlist são rejeitados.

Clock Skew

demoiselle.security.jwt.clockSkewSeconds=60  # tolerância em segundos (padrão)

P34 — Perfis avançados JWT

A validação possui três perfis. O default compat preserva tokens existentes; recommended e strict são opt-in e falham se issuer ou audience não estiverem configurados ou forem omitidos da chamada explícita de validação.

Perfil Requisitos adicionais
compat Mantém exp, assinatura, allowlist de algoritmo e seleção segura de kid
recommended iss, aud, header typ (JWT), iat, jti e idade máxima
strict Tudo de recommended mais sub
demoiselle.security.jwt.validationProfile=recommended
demoiselle.security.jwt.issuer=https://auth.example.gov.br
demoiselle.security.jwt.audience=api-demoiselle
demoiselle.security.jwt.expectedType=JWT
demoiselle.security.jwt.maxTokenAgeSeconds=900

Quando maxTokenAgeSeconds não é informado, os perfis avançados usam timetoLiveMilliseconds convertido para segundos. Overrides booleanos requireIssuer, requireAudience, requireIssuedAt, requireJwtId e requireSubject podem reforçar compat, mas false nunca enfraquece os pisos de recommended ou strict. Um nome de perfil desconhecido é rejeitado para evitar downgrade por erro de digitação.

Tokens emitidos pelo framework incluem typ quando o perfil o exige e usam a identidade como sub. A tolerância de clockSkewSeconds continua sendo aplicada à expiração e à idade baseada em iat.


P23 — Claims Customizados via ClaimsEnricher

Interface CDI para injetar claims customizados durante criação/validação de tokens:

@ApplicationScoped
public class TenantClaimsEnricher implements ClaimsEnricher {

    @Override
    public void enrich(JwtClaims claims, DemoiselleUser user) {
        claims.setClaim("tenant_id", resolveTenantId(user));
    }

    @Override
    public void extract(JwtClaims claims, DemoiselleUser user) {
        String tenantId = claims.getStringClaimValue("tenant_id");
        user.getParams().put("tenant_id", tenantId);
    }
}

Múltiplas implementações são suportadas simultaneamente. Quando nenhuma está registrada, o comportamento é idêntico ao anterior.


P24 — Eventos de Segurança via CDI

Records Java para eventos de autenticação e autorização:

// Observar login/logout/falha
public void onAuth(@Observes AuthenticationEvent event) {
    log.info("{} {} at {}", event.action(), event.user().getIdentity(), event.timestamp());
}

// Observar negação de autorização
public void onDenied(@Observes AuthorizationEvent event) {
    log.warn("Acesso negado: {} tentou acessar {}/{}",
        event.user().getIdentity(), event.resource(), event.operation());
}
Evento Ação Disparado por
AuthenticationEvent LOGIN SecurityContext.setUser()
AuthenticationEvent LOGOUT SecurityContext.removeUser()
AuthenticationEvent FAILURE AuthenticatedInterceptor
AuthorizationEvent RequiredRoleInterceptor, RequiredPermissionInterceptor

P25 — @RequiredAnyRole e @RequiredAllPermissions

Anotações com lógica AND/OR explícita para autorização:

// OR: usuário precisa de pelo menos UMA das roles
@RequiredAnyRole({"admin", "manager", "supervisor"})
public void aprovarPedido(Long id) { ... }

// AND: usuário precisa de TODAS as permissões
@RequiredAllPermissions({
    @Permission(resource = "pedido", operation = "aprovar"),
    @Permission(resource = "financeiro", operation = "consultar")
})
public void aprovarPedidoFinanceiro(Long id) { ... }

Usuário não autenticado → 401. Usuário sem roles/permissões → 403.


P26 — Configuração CORS via Properties

# demoiselle.properties
demoiselle.security.cors.allowedOrigins=https://app.example.com,https://admin.example.com
demoiselle.security.cors.allowedMethods=GET,POST,PUT,DELETE,OPTIONS
demoiselle.security.cors.allowedHeaders=Authorization,Content-Type,X-Requested-With
demoiselle.security.cors.maxAge=3600

Quando allowedOrigins contém *, qualquer origem é permitida. Sem configuração, o comportamento anterior é mantido.


P27 — @CacheControl com Atributos Tipados

// Antes (string bruta, propenso a erros de digitação)
@CacheControl("max-age=3600, no-store")

// Depois (atributos tipados com validação em compilação)
@CacheControl(maxAge = 3600, noStore = true)

// Combinação de atributos
@CacheControl(maxAge = 86400, mustRevalidate = true, isPrivate = true)

// Modo legado mantido para retrocompatibilidade
@CacheControl("no-cache, no-store, must-revalidate")

Quando value() está vazio, os atributos tipados são usados. Quando value() está preenchido, ele prevalece (modo legado).


P28 — Perfis de Configuração e @DefaultValue

Perfis

# Via propriedade de sistema
java -Ddemoiselle.profile=dev -jar app.jar

# Via variável de ambiente
export DEMOISELLE_PROFILE=prod

O framework resolve automaticamente demoiselle-dev.properties (ou demoiselle-prod.properties) com fallback para demoiselle.properties:

demoiselle-dev.properties   → chaves específicas do perfil
demoiselle.properties       → fallback para chaves ausentes

@DefaultValue

@Configuration(prefix = "app")
public class AppConfig {

    @DefaultValue("8080")
    private int port;

    @DefaultValue("localhost")
    private String host;

    @DefaultValue("INFO")
    private LogLevel logLevel;  // enums suportados
}

Quando a chave não existe no arquivo de configuração, o valor da anotação é usado. Quando a chave existe, o valor do arquivo prevalece.


P29 — Core API: Result Tipado e Métodos de Conveniência

Result<T>

// Antes: cast necessário
List<?> content = result.getContent();
List<Produto> produtos = (List<Produto>) content;

// Depois: type-safe
Result<Produto> result = dao.find();
List<Produto> produtos = result.getContent(); // sem cast

Métodos Default no Crud

// Novos métodos com implementação default
boolean exists = dao.exists(42L);       // delega para find(id) != null
long total = dao.count();               // delega para find().getContent().size()
List<Produto> all = dao.findAll();      // delega para find().getContent()

DemoiselleException com Error Code

throw new DemoiselleRestException("Token expirado", "DEMOISELLE-SEC-001");

// toString() inclui o código
// "DemoiselleRestException[DEMOISELLE-SEC-001]: Token expirado"

Formato: DEMOISELLE-<MÓDULO>-<NÚMERO> (ex: DEMOISELLE-SEC-001, DEMOISELLE-CFG-002).


P30 — Security Token: Correções e Modernização

Correção do validate(issuer, audience)

O método agora verifica efetivamente os parâmetros issuer e audience do usuário armazenado (antes eram ignorados).

TTL e Eviction

// Entradas expiradas são removidas automaticamente
// TTL padrão: 3600 segundos (1 hora)
// Limpeza executada a cada chamada de setUser()

TokenEntry Record

// Antes: DemoiselleUser armazenado diretamente
// Depois: record com timestamp de expiração
record TokenEntry(DemoiselleUser user, Instant expiresAt) {}

Resolução de Ambiguidade CDI no Token

O TokenImpl foi marcado com @Vetoed para evitar ambiguidade CDI (WELD-001409) no WildFly/Weld. O Token request-scoped é produzido exclusivamente pelo producer em SecurityFilter:

// SecurityFilter.java — fonte única do Token no container CDI
@Produces
@RequestScoped
public Token produceToken() {
    if (currentToken != null) {
        return currentToken;  // TokenImpl mutável com key/type do header
    }
    return new TokenImpl();   // TokenImpl vazio (mutável)
}

O TokenManagerImpl do JWT continua usando token.setKey() e token.setType() normalmente, pois o producer entrega TokenImpl (mutável).

O TokenRecord (record imutável) permanece disponível para uso externo onde imutabilidade é desejada, mas não é usado como bean CDI.

Correções de Ambiguidade CDI no Observability

Os adapters do módulo demoiselle-observability foram marcados com @Vetoed para evitar duplicidade com os beans sintéticos registrados pela ObservabilityExtension:

Classe Correção
NoopTracingAdapter @Vetoed — registrado via extensão
OpenTelemetryTracingAdapter @Vetoed — registrado via extensão
NoopMetricsAdapter @Vetoed — registrado via extensão
MicroProfileMetricsAdapter @Vetoed — registrado via extensão
HealthCheckProducer @Vetoed — registrado via extensão

P31 — Módulo MCP (demoiselle-mcp)

Módulo para construção de servidores MCP (Model Context Protocol) com o Demoiselle Framework. Permite expor beans CDI como ferramentas, recursos e prompts MCP via anotações declarativas, sem escrever código de infraestrutura de protocolo.

O módulo utiliza JSON-RPC 2.0 como formato de mensagens e suporta dois transportes: SSE (Server-Sent Events) via JAX-RS e stdio para comunicação local entre processos.

Dependências

Apenas demoiselle-core e demoiselle-configuration são obrigatórias. Integrações com demoiselle-rest (ProblemDetail), demoiselle-security (JWT, @RateLimit), demoiselle-crud (PageResult, Specification) e demoiselle-observability (@Counted) são opcionais — o módulo degrada graciosamente quando ausentes.

<dependency>
    <groupId>org.demoiselle.jee</groupId>
    <artifactId>demoiselle-mcp</artifactId>
    <version>4.1.0-SNAPSHOT</version>
</dependency>

Anotações

@McpTool — Expor métodos como ferramentas MCP

@ApplicationScoped
public class CalculatorService {

    @McpTool(description = "Soma dois números inteiros")
    public int add(@McpParam(name = "a", description = "Primeiro operando") int a,
                   @McpParam(name = "b", description = "Segundo operando") int b) {
        return a + b;
    }
}

O inputSchema (JSON Schema) é gerado automaticamente a partir dos parâmetros do método. Use @McpParam para personalizar nome, descrição e obrigatoriedade:

@McpTool(name = "buscar-produtos", description = "Busca produtos por filtro")
public List<Produto> buscar(
        @McpParam(name = "categoria", description = "Categoria do produto") String categoria,
        @McpParam(name = "precoMax", description = "Preço máximo", required = false) Double precoMax) {
    // ...
}

@McpResource — Expor dados como recursos MCP

@McpResource(uri = "config://app", name = "App Config",
             description = "Configuração da aplicação", mimeType = "application/json")
public String readConfig() {
    return "{ \"version\": \"1.0\" }";
}

@McpPrompt — Expor templates de prompt MCP

@McpPrompt(name = "code-review", description = "Revisa código fonte")
public List<Map<String, Object>> codeReview(
        @McpParam(name = "code", description = "Código a revisar") String code) {
    return List.of(Map.of(
        "role", "user",
        "content", Map.of("type", "text", "text", "Revise este código: " + code)
    ));
}

Mapeamento de Tipos Java → JSON Schema

Tipo Java JSON Schema
String {"type": "string"}
int/Integer/long/Long {"type": "integer"}
double/Double/float/Float {"type": "number"}
boolean/Boolean {"type": "boolean"}
List<T>/T[] {"type": "array", "items": {...}}
POJO {"type": "object", "properties": {...}}

Configuração via demoiselle.properties

# Nome e versão do servidor MCP
demoiselle.mcp.server.name=minha-aplicacao
demoiselle.mcp.server.version=2.0.0

# Transporte: "sse" (padrão) ou "stdio"
demoiselle.mcp.transport=sse

# Autenticação JWT no transporte SSE
demoiselle.mcp.security.enabled=false

# Desabilitar ferramentas específicas sem remover código
demoiselle.mcp.tools.disabled=ferramenta-debug, ferramenta-teste

Transporte SSE

O transporte SSE expõe dois endpoints JAX-RS:

  • GET /mcp/sse — estabelece conexão SSE, retorna evento endpoint com a URI do POST
  • POST /mcp/messages?sessionId=... — recebe mensagens JSON-RPC, retorna respostas via SSE
Cliente MCP                          Servidor
    │                                    │
    │── GET /mcp/sse ──────────────────>│
    │<── event: endpoint ───────────────│  (URI do POST)
    │                                    │
    │── POST /mcp/messages ────────────>│  (initialize)
    │<── event: message ────────────────│  (capabilities)
    │                                    │
    │── POST /mcp/messages ────────────>│  (tools/call)
    │<── event: message ────────────────│  (resultado)

Transporte stdio

Para comunicação local entre processos (ex.: integração com IDEs):

java -cp app.jar org.demoiselle.jee.mcp.transport.McpStdioTransport

Lê JSON-RPC de stdin (uma mensagem por linha), escreve respostas em stdout. Logs vão para stderr.

Segurança JWT (opcional)

Quando demoiselle.mcp.security.enabled=true e demoiselle-security está no classpath:

  • Conexões SSE exigem token JWT válido no header Authorization: Bearer <token>
  • Token ausente/inválido → HTTP 401
  • Token expirado → HTTP 401 com detail “Token expired”
  • O transporte stdio ignora configurações de segurança (contexto local confiável)

Integrações Opcionais

Módulo Integração Comportamento sem o módulo
demoiselle-rest Erros formatados como ProblemDetail (RFC 9457) Erros como texto simples
demoiselle-security Autenticação JWT, @RateLimit por ferramenta Sem autenticação, sem rate limit
demoiselle-crud PageResult com metadados de paginação, SpecificationBuilder PageResult tratado como POJO
demoiselle-observability Contadores @Counted por ferramenta Sem métricas

Protocolo JSON-RPC 2.0

O handler central valida e roteia mensagens conforme a especificação MCP:

Método Descrição
initialize Negociação de capacidades (tools, resources, prompts)
notifications/initialized Marca sessão como ativa
tools/list Lista ferramentas registradas com inputSchema
tools/call Invoca ferramenta por nome com argumentos JSON
resources/list Lista recursos registrados
resources/read Lê conteúdo de um recurso por URI
prompts/list Lista prompts registrados com argumentos
prompts/get Executa prompt por nome com argumentos

Códigos de erro JSON-RPC:

Código Significado
-32600 Requisição inválida (jsonrpc ≠ “2.0” ou sessão não inicializada)
-32601 Método desconhecido
-32602 Ferramenta/recurso/prompt inexistente
-32603 Erro interno do servidor
-32700 JSON malformado

P35 — Segurança, SPIs e contratos operacionais

Este ciclo fecha os itens de hardening e operação identificados na auditoria. O guia de migração 4.1 contém exemplos completos e impactos de compatibilidade.

Segurança atômica e proxies

SecurityStore abstrai contadores atômicos com TTL. O backend local é limitado e usado por rate limit, brute force e proteção de replay. A identidade autenticada é a chave preferencial; X-Forwarded-For só é lido quando o peer imediato está em demoiselle.security.trustedProxies.

MCP

Quando a segurança está ativa, GET e POST exigem Bearer JWT e a sessão permanece vinculada ao mesmo principal. Ausência do módulo JWT falha fechado. TTL absoluto, idle timeout, teto de sessões e limite de tools são configuráveis:

demoiselle.mcp.security.enabled=true
demoiselle.mcp.sessionTtlMillis=1800000
demoiselle.mcp.sessionIdleMillis=300000
demoiselle.mcp.maxSessions=10000
demoiselle.mcp.toolRateLimitRequests=60
demoiselle.mcp.toolRateLimitWindowSeconds=60

HashCash e rotação JWT

O módulo demoiselle-security-hashcash está no BOM/reactor e exige segredo de pelo menos 32 bytes. Seus desafios são assinados, vinculados a recurso e protegidos contra replay. JWT oferece JwtKeyProvider; o provider local suporta múltiplos kid, refresh e janela de rotação, rejeitando identificadores desconhecidos sem fallback.

Cache e segredos

CacheBackend permite substituir o cache CRUD; o backend local é LRU limitado, com TTL e métricas. SecretProvider é descoberto via ServiceLoader e inclui os schemes env, sys e file:

app.password=${secret:env:APP_PASSWORD}
app.token=${secret:sys:app.token}
app.key=${secret:file:/run/secrets/app-key}

Falhas não são cacheadas e nunca retornam segredo default.

Idempotência, outbox, cursor e lifecycle

  • @Idempotent oferece aquisição atômica, conflito durante processamento e replay byte-for-byte/JSON de respostas 2xx.
  • OutboxService publica em AFTER_SUCCESS; stores e publishers externos implementam OutboxStore/OutboxPublisher.
  • CursorCodec assina cursores HMAC-SHA256 e KeysetPagination constrói a comparação lexicográfica ASC/DESC, BEFORE/AFTER. Inclua chave única como último sort.
  • @ApiLifecycle adiciona Deprecation, Sunset e links sem sobrescrever headers da aplicação.

Result e Script

Result<T> é leitura; mutação pertence a MutableResult<T>. ResultSet implementa o contrato mutável e PageResult permanece imutável. No módulo Script, um ReadWriteLock por engine protege todo o ciclo de vida e o cache compartilhado.

CI e inventário

O reactor contém 16 módulos. Inventário/testes e matriz de runtimes são gerados por scripts padrão-library-only; module_inventory.py --check bloqueia drift. SBOM CycloneDX 1.6, checksums/proveniência, actions pinadas e smoke tests fazem parte da CI. O gate OpenAPI entra em ação quando o projeto possui uma spec versionada ou gerada.

Extensões de produção para produtos

As implementações locais mantêm desenvolvimento e testes autocontidos. Produtos com requisitos de cluster, integração e release podem usar seis extensões:

  1. estado distribuído para segurança, cache e idempotência consistentes;
  2. chaves e segredos gerenciados com rotação e auditoria centralizadas;
  3. outbox persistente e broker para publicação confiável após commit;
  4. matriz executada para certificar o runtime realmente usado pelo produto;
  5. baseline OpenAPI para impedir quebras de contrato antes do deploy; e
  6. builds herméticos para tornar reprodutibilidade um gate de release.

Consulte Extensões de produção para produtos Demoiselle para critérios dos adapters, benefícios, riscos de fallback, testes de falha e uma sequência recomendada de adoção.


Testes Baseados em Propriedades (jqwik)

O framework usa jqwik em uma suíte ampla de property-based tests que validam propriedades universais de corretude. A quantidade evolui com a suíte; os relatórios Surefire/Failsafe do build são a fonte de verdade:

# Propriedade Módulo
1 Rejeição de campos blank no SortModel crud
2 Igualdade estrutural de Records crud
3 Round-trip JSON do DemoiselleRestExceptionMessage rest
4 Independência de cópia defensiva no ResultSet crud
5 Cópias defensivas no DemoiselleUserImpl security
6 Null-safety do FilterOp.key() (13 variantes) crud
7 resolveFilterOp() sempre retorna FilterOp válido crud
8 Wildcard resolve para Like crud
9 Exclusão correta de campos no CriteriaUpdate crud
10 Soft delete marca registro em vez de remover crud
11 Consultas excluem registros soft-deleted crud
12 findIncludingDeleted retorna todos os registros crud
13 Persist preenche apenas campos de criação crud
14 Merge preenche apenas campos de atualização crud
15 Specification.and() retorna interseção crud
16 Specification.or() retorna união crud
17 Specification.not() retorna complemento crud
18 find(Specification) combina spec com filtros DRC crud
19 find(Specification) aplica paginação crud
20 persistAll retorna lista de mesmo tamanho crud
21 removeAll retorna contagem correta crud
22 updateAll aplica updates e Specification crud
23 PageResult tipo correto baseado em paginação crud
24 PageResult calcula metadados corretamente crud
25 PageResult cópia defensiva crud
26 resolveFilterOp resolve prefixos de operador crud
27 Prefixos de operador têm precedência crud
28 Cache round-trip (hit/miss com TTL) crud
29 Operações de escrita disparam EntityModifiedEvent crud
30 Invalidação de cache por entityClass crud
31 Contagem monotônica do @Counted observability
32 Segurança dos adapters noop observability
33 Span do @Traced contém atributos corretos observability
34 Agregação de OpenAPIContributors preserva paths openapi
35 Tolerância a falhas na agregação OpenAPI openapi
36 Interceptor de segurança aceita tokens válidos e rejeita inválidos integration-tests
37 Round-trip de configuração integration-tests
38 Round-trip de claims JWT integration-tests
39 Invariante do rate limiter integration-tests
40 Round-trip serialização ProblemDetail (RFC 9457) rest
41 Validação de chaves de extensão do ProblemDetail rest
42 About:blank preenche title com frase-razão HTTP rest
43 Round-trip mapeamento ExceptionMessage → ProblemDetail rest
44 Múltiplas mensagens incluídas como extensão rest
45 Invariante status-consistente nas respostas RFC 9457 rest
46 Media type application/problem+json no formato RFC 9457 rest
47 Omissão de detail quando showErrorDetails é false rest
48 Instance preenchido com URI da requisição rest
49 Normalização de errorFormat desconhecido para legacy rest
50 Relações do header Link metamórficas com PageResult crud
51 Invariante offset-limit consistente nas URIs do Link crud
52 Preservação de query parameters no LinkHeaderBuilder crud
53 Headers customizados consistentes com PageResult e Link crud
54 Rate limiter rejeita (N+1)-ésima requisição com Retry-After security
55 Resposta 429 contém Retry-After consistente security
56 Invariante de schema válido para @McpTool mcp
57 Mapeamento correto de tipos Java → JSON Schema mcp
58 Metadados de parâmetros (required e description) mcp
59 Rejeição de nomes/URIs duplicados nos registros mcp
60 Consistência entre registros e respostas de listagem mcp
61 Lookup por nome/URI inexistente retorna -32602 mcp
62 isError reflete exceção do método CDI mcp
63 Round-trip de serialização de argumentos e resultados mcp
64 Round-trip do transporte stdio mcp
65 Capabilities refletem estado dos registros mcp
66 Requisições pré-handshake rejeitadas com -32600 mcp
67 Validação JSON-RPC e consistência de id mcp
68 Método desconhecido retorna -32601 mcp
69 Notificações não produzem resposta mcp
70 Formatação de erros condicional ao classpath mcp
71 Mapeamento de tipo de exceção para status ProblemDetail mcp
72 Serialização de PageResult com metadados de paginação mcp
73 Resposta de rate limit mcp
74 Contadores de métricas auto-registrados mcp
75 Filtragem de ferramentas desabilitadas mcp
76 Round-trip de serialização JsonRpcMessage mcp
77 Campos null omitidos na serialização mcp
78 Exclusividade mútua entre error e result mcp
79 SpecificationBuilder suporta todos os operadores mcp
80 PageResult como objeto simples sem demoiselle-crud mcp
81 Geração de argumentos de prompt a partir de parâmetros mcp

Exemplo — Property test de cópia defensiva:

@Property(tries = 200)
void modifyingOriginalListDoesNotAffectGetContent(
        @ForAll List<String> elements) {

    List<String> mutableList = new ArrayList<>(elements);
    ResultSet rs = new ResultSet();
    rs.setContent(mutableList);

    List<?> snapshot = rs.getContent();

    mutableList.add("EXTRA");
    mutableList.clear();

    assertEquals(elements.size(), snapshot.size(),
        "Modificações na lista original não devem afetar getContent()");
}

Guia de Migração — Demoiselle v3 → v4

Pré-requisitos

Componente Versão Mínima
Java 17+ (LTS)
Maven 3.9+
Jakarta EE 10
CDI 4.0
JPA 3.1
JAX-RS 3.1
Weld (referência CDI) 5.x
Servidor de Aplicação WildFly 27+, Quarkus ou Open Liberty

Parte I — Migração do Build

1. Atualizar o POM

<parent>
    <groupId>org.demoiselle.jee</groupId>
    <artifactId>demoiselle-parent</artifactId>
    <version>4.1.0-SNAPSHOT</version>
</parent>

Configurar Java 21 no compiler plugin:

<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.13.0</version>
    <configuration>
        <release>21</release>
    </configuration>
</plugin>

2. Namespace Jakarta

Todas as importações javax.* devem ser substituídas por jakarta.*:

// Antes
import javax.inject.Inject;
import javax.enterprise.context.ApplicationScoped;
import javax.persistence.Entity;
import javax.ws.rs.GET;

// Depois
import jakarta.inject.Inject;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.Entity;
import jakarta.ws.rs.GET;
Antigo Novo
javax.enterprise.* jakarta.enterprise.*
javax.inject.* jakarta.inject.*
javax.ws.rs.* jakarta.ws.rs.*
javax.persistence.* jakarta.persistence.*
javax.validation.* jakarta.validation.*
javax.servlet.* jakarta.servlet.*
javax.annotation.* jakarta.annotation.*
javax.json.* jakarta.json.*

javax.script.* (API do JDK) não deve ser alterado.

3. Atualizar beans.xml

<!-- Antes -->
<beans xmlns="http://xmlns.jcp.org/xml/ns/javaee"
       xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee
       http://xmlns.jcp.org/xml/ns/javaee/beans_1_1.xsd"
       bean-discovery-mode="all">

<!-- Depois -->
<beans xmlns="https://jakarta.ee/xml/ns/jakartaee"
       xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
       https://jakarta.ee/xml/ns/jakartaee/beans_4_0.xsd"
       version="4.0" bean-discovery-mode="all">

4. Renomear Arquivos SPI

# Antes
META-INF/services/javax.enterprise.inject.spi.Extension

# Depois
META-INF/services/jakarta.enterprise.inject.spi.Extension

5. Remover DeltaSpike

O Demoiselle 4 não depende mais do Apache DeltaSpike:

  • @MessageBundle / @MessageTemplate → use as anotações do Demoiselle em org.demoiselle.jee.core.annotation
  • CdiTestRunner → substitua por Weld JUnit 5 (@EnableAutoWeld)

6. Migrar Testes para JUnit 5

JUnit 4 JUnit 5
@org.junit.Test @org.junit.jupiter.api.Test
@Before / @After @BeforeEach / @AfterEach
@BeforeClass / @AfterClass @BeforeAll / @AfterAll
@Ignore @Disabled
@RunWith(CdiTestRunner.class) @EnableAutoWeld
Assert.assertEquals(...) Assertions.assertEquals(...)

7. Remover WildFly Swarm

O profile wildfly-swarm foi removido. Para runtimes embarcados, use WildFly 27+ (Galleon), Quarkus ou Open Liberty.

8. Migrar Swagger → OpenAPI

Swagger 1.x OpenAPI 3.0 (MicroProfile)
@Api @Tag
@ApiOperation @Operation
@ApiParam @Parameter
io.swagger:swagger-jaxrs org.eclipse.microprofile.openapi:microprofile-openapi-api

9. Atualizar Groovy (se aplicável)

<!-- Antes -->
<dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy-all</artifactId>
</dependency>

<!-- Depois -->
<dependency>
    <groupId>org.apache.groovy</groupId>
    <artifactId>groovy-all</artifactId>
    <type>pom</type>
</dependency>

Parte II — Mudanças de API e Comportamento

10. Records — DTOs e Mensagens

Classe Antes Depois
SortModel Classe mutável Record imutável
DemoiselleRestExceptionMessage Classe com getters Record (error(), errorDescription(), errorLink())
ResultSet setContent(null) → NPE setContent(null) → lista vazia
PageResult Não existia Record com metadados de paginação
TokenEntry DemoiselleUser direto no mapa Record TokenEntry(user, expiresAt)
// Antes: getError(), getErrorDescription(), getErrorLink()
msg.getError();

// Depois: error(), errorDescription(), errorLink()
msg.error();

11. Sealed Classes — FilterOp

// Antes: strings e if-else no DAO
if (value == null) { /* isNull */ }
else if (value.contains("*")) { /* like */ }

// Depois: pattern matching exaustivo
return switch (op) {
    case FilterOp.IsNull(var key)   -> cb.isNull(from.get(key));
    case FilterOp.Like(var key, var p) -> buildLikePredicate(cb, cq, from, key, p);
    // ... compilador garante exaustividade
};

Novos operadores de comparação via query string (gt:, lt:, gte:, lte:, between:, in:) — sem alteração necessária em código existente.

12. Coleções Imutáveis — Segurança

// Antes: Collections.unmodifiableList() — view mutável
List<String> roles = user.getRoles(); // view
user.addRole("admin");
roles.contains("admin"); // true (!)

// Depois: List.copyOf() — cópia defensiva
List<String> roles = user.getRoles(); // cópia independente
user.addRole("admin");
roles.contains("admin"); // false

Se seu código dependia de views mutáveis, ajuste para re-obter a lista após modificações.

13. Result Tipado

// Antes: raw type, cast necessário
Result result = dao.find();
List<?> content = result.getContent();

// Depois: genérico, type-safe
Result<Produto> result = dao.find();
List<Produto> content = result.getContent();

Código existente com raw type continua compilando (apenas warnings).

14. Formato de Erro REST — RFC 9457

Nenhuma alteração necessária por padrão. O formato legado é mantido:

# demoiselle.properties — padrão (sem alteração)
# demoiselle.rest.errorFormat=legacy

# Para ativar RFC 9457 (opt-in)
demoiselle.rest.errorFormat=rfc9457

Se seu frontend parseia os campos error, error_description, error_link, ele continua funcionando. Para migrar para RFC 9457, ajuste o parser para os campos type, title, status, detail, instance.

15. Headers de Paginação — RFC 8288

O header Link é adicionado automaticamente. Headers customizados (X-Total-Count, etc.) continuam presentes. Nenhuma alteração necessária.

Se seu frontend já usa os headers X-*, nada muda. Para adotar o padrão RFC 8288, passe a usar o header Link com as relações next, prev, first, last.

16. Rate Limiting — HTTP 429

// Antes: DemoiselleSecurityException genérica
catch (DemoiselleSecurityException e) { /* status 429 */ }

// Depois: Response JAX-RS direta com Retry-After
// O interceptor retorna Response 429 diretamente
// Se seu código capturava a exceção, ajuste para tratar a resposta HTTP

O header Retry-After agora é incluído automaticamente. Clientes podem implementar backoff baseado nesse valor.

17. JWT — Novas Funcionalidades e Defaults Seguros

# Refresh token (opt-in)
demoiselle.security.jwt.refreshTokenTtlMilliseconds=86400000

# Identificador da chave configurada
demoiselle.security.jwt.activeKeyId=key-2024
demoiselle.security.jwt.privateKey=...
demoiselle.security.jwt.publicKey=...

# Allowlist opcional; sem a propriedade, somente RS256 é aceito
demoiselle.security.jwt.allowedAlgorithms=RS256,RS384,RS512

# Clock skew (padrão 60s)
demoiselle.security.jwt.clockSkewSeconds=60

Atenção na migração: a validação agora é fail-closed para algoritmo e kid. Tokens com kid explícito diferente de activeKeyId (ou de uma chave conhecida por um provedor futuro) recebem 401. Valide emissores legados antes de atualizar. Propriedades aninhadas keys.<kid>.* não são suportadas nesta versão.

18. Token — Resolução de Ambiguidade CDI

// Antes: TokenImpl era @RequestScoped (bean CDI normal)
// Problema: ambiguidade com o producer em SecurityFilter (WELD-001409)

// Depois: TokenImpl é @Vetoed (não é mais bean CDI)
// O Token request-scoped é produzido exclusivamente pelo SecurityFilter
// setKey() e setType() continuam funcionando normalmente

// TokenRecord existe como alternativa imutável para uso externo,
// mas o producer do SecurityFilter entrega TokenImpl (mutável)
// para compatibilidade com TokenManagerImpl.setUser()

19. Security Token — validate(issuer, audience)

O método validate(issuer, audience) agora verifica efetivamente os parâmetros. Se seu código dependia do comportamento anterior (parâmetros ignorados), ajuste os params do DemoiselleUser para incluir issuer e audience.

20. Configuração — Perfis e @DefaultValue

Funcionalidades opt-in. Sem configuração de perfil, o comportamento é idêntico ao anterior:

# Ativar perfil (opt-in)
java -Ddemoiselle.profile=dev -jar app.jar
# ou
export DEMOISELLE_PROFILE=prod

21. @CacheControl Tipado

// Antes (continua funcionando)
@CacheControl("max-age=3600, no-store")

// Depois (nova opção)
@CacheControl(maxAge = 3600, noStore = true)

O atributo value() tem precedência quando preenchido — código existente não quebra.

22. CORS via Properties

# Opt-in — sem configuração, comportamento anterior mantido
demoiselle.security.cors.allowedOrigins=https://app.example.com
demoiselle.security.cors.allowedMethods=GET,POST,PUT,DELETE
demoiselle.security.cors.allowedHeaders=Authorization,Content-Type
demoiselle.security.cors.maxAge=3600

23. Novas Anotações de Segurança

Aditivas — não afetam código existente:

@RequiredAnyRole({"admin", "manager"})     // OR — nova
@RequiredAllPermissions({...})              // AND — nova
@RequiredRole("admin")                      // existente — inalterada
@RequiredPermission(resource="x", op="y")  // existente — inalterada

24. Observabilidade e OpenAPI

Módulos opcionais. Basta adicionar a dependência ao pom.xml:

<dependency>
    <groupId>org.demoiselle.jee</groupId>
    <artifactId>demoiselle-observability</artifactId>
</dependency>
<dependency>
    <groupId>org.demoiselle.jee</groupId>
    <artifactId>demoiselle-openapi</artifactId>
</dependency>

Sem a dependência, nenhum comportamento muda.

Parte III — Mudanças de Comportamento

Componente Antes (v3) Depois (v4)
SecurityContextImpl hasPermission()/hasRole() → NPE sem usuário Retorna false
TokenManagerImpl removeUser()UnsupportedOperationException Limpa token do request scope
KeyPairHolder Campos static @ApplicationScoped CDI bean
AbstractDAO Mensagens hardcoded, Exception genérica Message bundles, exceções específicas
CrudFilter Projeção limitada a 2 níveis Profundidade arbitrária + ReflectionCache

Checklist de Migração

  • Atualizar versão do parent POM para 4.1.0-SNAPSHOT
  • Configurar Java 21 no maven-compiler-plugin
  • Substituir imports javax.* por jakarta.*
  • Atualizar beans.xml para namespace Jakarta EE
  • Renomear arquivos META-INF/services/javax.* para jakarta.*
  • Remover dependências DeltaSpike
  • Migrar testes de JUnit 4 para JUnit 5
  • Remover configurações WildFly Swarm
  • Migrar anotações Swagger para OpenAPI (se aplicável)
  • Atualizar Groovy para org.apache.groovy (se aplicável)
  • Atualizar servidor de aplicação para Jakarta EE 10
  • Ajustar accessors de records (getError()error())
  • Verificar código que depende de views mutáveis de coleções
  • Testar a aplicação completa

Resumo de Breaking Changes

Mudança Impacto Ação
Baseline Java 21 Alto Atualizar JDK local, CI e imagens de runtime
javax.*jakarta.* Alto Substituir imports
Demoiselle-Version oculto Baixo Ativar demoiselle.rest.exposeFrameworkVersion=true somente se necessário
Limites CRUD globais Médio Ajustar propriedades se a API aceita páginas/filtros maiores
Record accessors (error() vs getError()) Médio Atualizar chamadas
List.copyOf() em coleções de segurança Baixo Re-obter lista após mutação
TokenImpl agora @Vetoed (não é bean CDI) Baixo Token vem do producer do SecurityFilter
validate(issuer, audience) corrigido Baixo Verificar params do user
Rate limit retorna Response (não exceção) Baixo Ajustar catch se aplicável

Resumo de Funcionalidades Opt-in (sem breaking change)

Funcionalidade Configuração
RFC 9457 Problem Details demoiselle.rest.errorFormat=rfc9457
RFC 8288 Link headers Automático (aditivo)
JWT Refresh Token demoiselle.security.jwt.refreshTokenTtlMilliseconds
JWT Key Rotation demoiselle.security.jwt.activeKeyId
JWT Múltiplos Algoritmos demoiselle.security.jwt.allowedAlgorithms
JWT Validation Profiles demoiselle.security.jwt.validationProfile=recommended ou strict
Perfis de Configuração -Ddemoiselle.profile=dev
@DefaultValue Anotação em campos @Configuration
CORS via Properties demoiselle.security.cors.*
@CacheControl tipado Atributos na anotação
@RequiredAnyRole Nova anotação
@RequiredAllPermissions Nova anotação
Observabilidade Dependência Maven
OpenAPI Dependência Maven
MCP (Model Context Protocol) Dependência Maven + demoiselle.mcp.*