| layout | default |
|---|---|
| title | Demoiselle Framework v4 — Modernização Jakarta EE 10 |
| permalink | /docs/ |
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]({{ '/' | relative_url }}) · [Migração 4.1]({{ '/docs/migration-4.1.html' | relative_url }}) · [Extensões de produção]({{ '/docs/production-extensions.html' | relative_url }})
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.
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.
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.
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 nullSerializaçã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_description→errorDescriptioneerror_link→errorLinkpara seguir convenções Java.
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)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
) {}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 compacto — key nunca é null, Like.pattern nunca é null, EnumFilter.ordinal é ≥ 0, etc.
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 |
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.
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.
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
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 é independenteValidação de null em addRole():
user.addRole(null); // → NullPointerExceptionO 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)
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<>());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.
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()retornanull— nenhum hint é aplicado e o comportamento existente de paginação, ordenação e filtragem é mantido.
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.
@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
}// LocalDateTime (padrão)
@SoftDeletable(field = "deletedAt")
// Boolean
@SoftDeletable(field = "deleted", type = Boolean.class)
// Instant
@SoftDeletable(field = "deletedInstant", type = Instant.class)// 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)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)// 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 { }Preenchimento automático de campos de auditoria via JPA EntityListener, sem código manual em cada entidade.
| Anotação | Evento | Valor |
|---|---|---|
@CreatedAt |
@PrePersist |
LocalDateTime.now() |
@CreatedBy |
@PrePersist |
DemoiselleUser.getIdentity() |
@UpdatedAt |
@PreUpdate |
LocalDateTime.now() |
@UpdatedBy |
@PreUpdate |
DemoiselleUser.getIdentity() |
@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
}// 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"Quando DemoiselleUser não está disponível (ex.: operações em batch sem requisição HTTP), o valor de @CreatedBy/@UpdatedBy é "system".
Composição declarativa de consultas JPA complexas sem escrever CriteriaQuery manualmente.
@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() { ... }
}// 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);// 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);// 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()Processamento eficiente de grandes volumes com flush/clear automático para evitar estouro de memória.
# demoiselle.properties
demoiselle.crud.batch.size=100Valor 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.
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() == 3Em 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
}List<Long> ids = List.of(1L, 2L, 3L, 4L, 5L);
// Remove todos (respeita @SoftDeletable quando presente)
int removidos = dao.removeAll(ids);
// removidos == 5// 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'Record imutável com metadados completos de paginação, eliminando cálculos manuais no frontend.
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 { }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)// 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 = trueQuando 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, ...
6 novos operadores de comparação via query string, sem necessidade de endpoints customizados.
| 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 |
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
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)
# 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)
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 {}
}Cache automático de resultados de consultas com invalidação reativa via eventos CDI.
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
}
}- Requisição GET chega ao endpoint
@Cacheable CacheInterceptorverifica se existe resultado em cache para a chaveentityClass:method:paramsHash- Cache hit → retorna resultado imediatamente (header
X-Cache: HIT) - Cache miss → executa o método, armazena resultado no cache (header
X-Cache: MISS)
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 intactasEm 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.
# 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
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ CrudFilter │────▶│ CacheInterceptor │────▶│ QueryCacheStore │
│ (JAX-RS) │ │ (CDI Interceptor)│ │ (ConcurrentMap) │
└─────────────┘ └──────────────────┘ └─────────────────┘
▲
│ invalidate
┌────────┴────────┐
│ CacheInvalidation│
│ Listener │
└────────┬────────┘
│ @Observes
┌────────┴────────┐
│EntityModifiedEvent│
└────────┬────────┘
│ fire()
┌────────┴────────┐
│ AbstractDAO │
│ persist/merge/ │
│ remove │
└─────────────────┘
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.
<dependency>
<groupId>org.demoiselle.jee</groupId>
<artifactId>demoiselle-observability</artifactId>
</dependency>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.
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.
// 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)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)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) |
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>Geração automática de documentação OpenAPI para endpoints do framework via MicroProfile OpenAPI.
<dependency>
<groupId>org.demoiselle.jee</groupId>
<artifactId>demoiselle-openapi</artifactId>
</dependency>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;
}
}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
# demoiselle.properties
# Desativar documentação OpenAPI automática (default: true)
demoiselle.openapi.enabled=falseQuando desativado, DemoiselleOASModelReader.buildModel() retorna um documento OpenAPI vazio.
O pipeline de CI/CD usa Java 21, o mesmo baseline exigido pelo build Maven.
# .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.jsonetarget/bom.xmlagregados na fasepackage. - 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.
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% |
<!-- 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.
Módulo dedicado a testes de integração que validam fluxos completos entre múltiplos módulos do framework.
- Executado na fase
verifydo Maven viamaven-failsafe-plugin - Testes unitários (
surefire) são desabilitados neste módulo - Módulos opcionais são tratados com
@EnabledIfdo JUnit 5
@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
}
}@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
}
}@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).
🌐 Padrão IETF — RFC 9457 — Respostas de erro padronizadas com
application/problem+json
Respostas de erro padronizadas conforme RFC 9457 com media type application/problem+json.
# demoiselle.properties
demoiselle.rest.errorFormat=rfc9457Valor padrão: "legacy" — formato anterior mantido para retrocompatibilidade. Qualquer valor diferente de "rfc9457" é normalizado para "legacy".
{
"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"}
]
}| 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 |
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"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".
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=trueO 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=SAMEORIGINPor redução de fingerprinting, Demoiselle-Version fica oculto por padrão. A
compatibilidade pode ser reativada explicitamente:
demoiselle.rest.exposeFrameworkVersion=true🌐 Padrão IETF — RFC 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.
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
| 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.
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", ...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.
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=10O 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.
🌐 Padrão IETF — RFC 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.
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"
}
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.
O valor do header Retry-After é calculado pelo SlidingWindowCounter com base no registro mais antigo na janela deslizante. O valor é sempre >= 1 e <= windowSeconds.
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)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.
# 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.
# Opcional; RS256 é o default seguro
demoiselle.security.jwt.allowedAlgorithms=RS256,RS384,RS512A lista nunca fica aberta: sem configuração explícita, somente RS256 é
permitido. Tokens sem algoritmo ou com algoritmo fora da allowlist são
rejeitados.
demoiselle.security.jwt.clockSkewSeconds=60 # tolerância em segundos (padrão)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=900Quando 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.
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.
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 |
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.
# 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=3600Quando allowedOrigins contém *, qualquer origem é permitida. Sem configuração, o comportamento anterior é mantido.
// 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).
# Via propriedade de sistema
java -Ddemoiselle.profile=dev -jar app.jar
# Via variável de ambiente
export DEMOISELLE_PROFILE=prodO 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
@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.
// 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// 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()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).
O método agora verifica efetivamente os parâmetros issuer e audience do usuário armazenado (antes eram ignorados).
// Entradas expiradas são removidas automaticamente
// TTL padrão: 3600 segundos (1 hora)
// Limpeza executada a cada chamada de setUser()// Antes: DemoiselleUser armazenado diretamente
// Depois: record com timestamp de expiração
record TokenEntry(DemoiselleUser user, Instant expiresAt) {}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.
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 |
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.
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>@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(uri = "config://app", name = "App Config",
description = "Configuração da aplicação", mimeType = "application/json")
public String readConfig() {
return "{ \"version\": \"1.0\" }";
}@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)
));
}| 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": {...}} |
# 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-testeO transporte SSE expõe dois endpoints JAX-RS:
GET /mcp/sse— estabelece conexão SSE, retorna eventoendpointcom a URI do POSTPOST /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)
Para comunicação local entre processos (ex.: integração com IDEs):
java -cp app.jar org.demoiselle.jee.mcp.transport.McpStdioTransportLê JSON-RPC de stdin (uma mensagem por linha), escreve respostas em stdout. Logs vão para stderr.
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)
| 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 |
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 |
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.
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.
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=60O 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.
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.
@Idempotentoferece aquisição atômica, conflito durante processamento e replay byte-for-byte/JSON de respostas 2xx.OutboxServicepublica emAFTER_SUCCESS; stores e publishers externos implementamOutboxStore/OutboxPublisher.CursorCodecassina cursores HMAC-SHA256 eKeysetPaginationconstrói a comparação lexicográfica ASC/DESC, BEFORE/AFTER. Inclua chave única como último sort.@ApiLifecycleadicionaDeprecation,Sunsete links sem sobrescrever headers da aplicação.
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.
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.
As implementações locais mantêm desenvolvimento e testes autocontidos. Produtos com requisitos de cluster, integração e release podem usar seis extensões:
- estado distribuído para segurança, cache e idempotência consistentes;
- chaves e segredos gerenciados com rotação e auditoria centralizadas;
- outbox persistente e broker para publicação confiável após commit;
- matriz executada para certificar o runtime realmente usado pelo produto;
- baseline OpenAPI para impedir quebras de contrato antes do deploy; e
- 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.
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()");
}| 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 |
<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>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.
<!-- 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"># Antes
META-INF/services/javax.enterprise.inject.spi.Extension
# Depois
META-INF/services/jakarta.enterprise.inject.spi.Extension
O Demoiselle 4 não depende mais do Apache DeltaSpike:
@MessageBundle/@MessageTemplate→ use as anotações do Demoiselle emorg.demoiselle.jee.core.annotationCdiTestRunner→ substitua por Weld JUnit 5 (@EnableAutoWeld)
| 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(...) |
O profile wildfly-swarm foi removido. Para runtimes embarcados, use WildFly 27+ (Galleon), Quarkus ou Open Liberty.
| Swagger 1.x | OpenAPI 3.0 (MicroProfile) |
|---|---|
@Api |
@Tag |
@ApiOperation |
@Operation |
@ApiParam |
@Parameter |
io.swagger:swagger-jaxrs |
org.eclipse.microprofile.openapi:microprofile-openapi-api |
<!-- 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>| 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();// 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.
// 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"); // falseSe seu código dependia de views mutáveis, ajuste para re-obter a lista após modificações.
// 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).
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=rfc9457Se 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.
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.
// 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 HTTPO header Retry-After agora é incluído automaticamente. Clientes podem implementar backoff baseado nesse valor.
# 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=60Atenção na migração: a validação agora é fail-closed para algoritmo e
kid. Tokens comkidexplícito diferente deactiveKeyId(ou de uma chave conhecida por um provedor futuro) recebem 401. Valide emissores legados antes de atualizar. Propriedades aninhadaskeys.<kid>.*não são suportadas nesta versão.
// 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()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.
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// 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.
# 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=3600Aditivas — não afetam código existente:
@RequiredAnyRole({"admin", "manager"}) // OR — nova
@RequiredAllPermissions({...}) // AND — nova
@RequiredRole("admin") // existente — inalterada
@RequiredPermission(resource="x", op="y") // existente — inalteradaMó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.
| 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 |
- Atualizar versão do parent POM para 4.1.0-SNAPSHOT
- Configurar Java 21 no maven-compiler-plugin
- Substituir imports
javax.*porjakarta.* - Atualizar
beans.xmlpara namespace Jakarta EE - Renomear arquivos
META-INF/services/javax.*parajakarta.* - 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
| 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 |
| 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.* |