📚 Série: Quarkus: Desvendando o Desenvolvimento Moderno com Java
Este é o quinto capítulo de uma série completa sobre Quarkus. Prepare-se para uma jornada que vai transformar sua visão sobre desenvolvimento Java moderno!
Você já tem uma aplicação Quarkus funcionando e sabe como gerenciar dependências e configurações. Agora é hora de construir APIs que o mundo pode consumir! 🌍
No desenvolvimento moderno, APIs RESTful são a ponte entre diferentes sistemas - são elas que permitem que sua aplicação mobile converse com o backend, que microsserviços se comuniquem, e que sistemas externos integrem com sua solução.
O Quarkus, com sua integração otimizada com RESTEasy, torna a criação de APIs RESTful uma experiência incrível. Vamos descobrir como! 💪
🎯 O que você vai aprender
- Conceitos fundamentais de REST e JAX-RS
- Como criar endpoints REST no Quarkus
- Tratamento robusto de erros e exceções
- Validação profissional de dados
- Manipulação de dados JSON/XML
- Boas práticas para APIs de produção
🏗️ Fundamentos: REST e JAX-RS Explicados
REST: Mais que um Protocolo, uma Filosofia
REST (Representational State Transfer) não é apenas uma tecnologia - é um estilo arquitetural que define como sistemas distribuídos devem se comunicar.
Os pilares do REST são:
🎯 Recursos como Cidadãos de Primeira Classe
- Tudo é um recurso: usuários, produtos, pedidos
- Cada recurso tem uma URL única:
/users/123
,/products/456
🔄 Stateless por Design
- Cada requisição é independente
- O servidor não "lembra" de requisições anteriores
- Isso facilita escalabilidade e cache
⚡ Verbos HTTP com Significado
-
GET
→ Buscar dados (sem efeitos colaterais) -
POST
→ Criar novos recursos -
PUT
→ Atualizar recursos completos -
DELETE
→ Remover recursos -
PATCH
→ Atualizações parciais
🎨 Múltiplas Representações
- O mesmo recurso pode ser JSON, XML, HTML
- O cliente escolhe o formato via headers
JAX-RS: REST para Desenvolvedores Java
JAX-RS (Java API for RESTful Web Services) é a especificação Java que transforma esses conceitos em código. É como ter um tradutor universal entre HTTP e Java.
// Isso é JAX-RS em ação! 🎉
@GET
@Path("/users/{id}")
@Produces("application/json")
public User getUser(@PathParam("id") Long id) {
return findUserById(id);
}
O RESTEasy é uma das implementações mais robustas do JAX-RS, e o Quarkus o integra de forma nativa e supersônica! 🚄
🛠️ Mãos à Obra: Criando Nossa API de Produtos
Vamos construir uma API completa para gerenciar produtos. É um exemplo prático que você pode adaptar para qualquer domínio!
💡 Dica Pro: A dependencia quarkus-rest
utiliza o resteasy-reactive
é a versão mais moderna e performática. Ele usa programação reativa por baixo dos panos, mesmo que você escreva código "tradicional"!
Passo 1: O Modelo de Dados
Vamos criar nossa classe Product - simples, mas poderosa:
package com.example.model;
public class Product {
public Long id;
public String name;
public String description;
public Double price;
// Construtor padrão (necessário para JSON)
public Product() {}
// Construtor completo
public Product(Long id, String name, String description, Double price) {
this.id = id;
this.name = name;
this.description = description;
this.price = price;
}
// Método útil para debugging
@Override
public String toString() {
return String.format("Product{id=%d, name='%s', price=%.2f}",
id, name, price);
}
}
🤔 Por que campos públicos? Quarkus foi desenhado para rodar de forma ultrarrápida, tanto no modo JVM quanto nativamente com GraalVM. Evitar reflection pesado, como o que é usado para acessar campos privados via getters/setters, melhora a performance da serialização/desserialização de JSON.
Frameworks como Jackson ou JSON-B conseguem acessar campos públicos diretamente, sem precisar de introspecção extra.
⚠️ Quando não usar campos públicos
Apesar da praticidade, campos públicos não são ideais em todos os cenários:
Quando você precisa de validações personalizadas ou lógica de negócio nos getters/setters.
Quando precisa proteger invariantes de estado da sua entidade.
Quando quer seguir padrões de encapsulamento rigorosos (como em DDD).
✅ Quando usar
Campos públicos são totalmente aceitáveis e até recomendados no Quarkus para:
DTOs
Modelos usados apenas para transporte
Testes e mocks
Classes simples e imutáveis
Passo 2: O Recurso REST - Onde a Mágica Acontece
Agora vem a parte mais interessante - nossa classe de recurso REST:
package com.example.resource;
import com.example.model.Product;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;
@Path("/products")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class ProductResource {
// Simulando um banco de dados em memória
private static final List<Product> products = new ArrayList<>();
private static final AtomicLong idGenerator = new AtomicLong();
// Dados de exemplo para testar
static {
products.add(new Product(
idGenerator.incrementAndGet(),
"MacBook Pro M3",
"Laptop profissional para desenvolvimento",
2999.99
));
products.add(new Product(
idGenerator.incrementAndGet(),
"Mouse MX Master 3",
"Mouse ergonômico para produtividade",
89.99
));
products.add(new Product(
idGenerator.incrementAndGet(),
"Teclado Mecânico RGB",
"Teclado para gamers e desenvolvedores",
159.99
));
}
@GET
public List<Product> getAllProducts() {
return products;
}
@GET
@Path("/{id}")
public Response getProductById(@PathParam("id") Long id) {
Optional<Product> product = products.stream()
.filter(p -> p.id == id)
.findFirst();
return product
.map(p -> Response.ok(p).build())
.orElse(Response.status(Response.Status.NOT_FOUND)
.entity("Produto não encontrado")
.build());
}
@POST
public Response createProduct(Product product) {
// Validação básica
if (product.name == null || product.name.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity("Nome do produto é obrigatório")
.build();
}
product.id = idGenerator.incrementAndGet();
products.add(product);
return Response.status(Response.Status.CREATED)
.entity(product)
.build();
}
@PUT
@Path("/{id}")
public Response updateProduct(@PathParam("id") Long id, Product updatedProduct) {
Optional<Product> existingProduct = products.stream()
.filter(p -> p.id == id)
.findFirst();
if (existingProduct.isPresent()) {
Product product = existingProduct.get();
product.name = updatedProduct.name;
product.description = updatedProduct.description;
product.price = updatedProduct.price;
return Response.ok(product).build();
}
return Response.status(Response.Status.NOT_FOUND)
.entity("Produto não encontrado")
.build();
}
@DELETE
@Path("/{id}")
public Response deleteProduct(@PathParam("id") Long id) {
boolean removed = products.removeIf(p -> p.id == id);
if (removed) {
return Response.noContent().build();
}
return Response.status(Response.Status.NOT_FOUND)
.entity("Produto não encontrado")
.build();
}
}
🎯 Decifrando as Anotações JAX-RS
Cada anotação tem um propósito específico:
-
@Path("/products")
→ Define a URL base (/products
) -
@Produces(APPLICATION_JSON)
→ "Eu falo JSON!" 🗣️ -
@Consumes(APPLICATION_JSON)
→ "Eu entendo JSON!" 👂 -
@GET
,@POST
, etc. → Mapeia métodos HTTP -
@PathParam("id")
→ Extrai valores da URL -
Response
→ Controle total sobre status codes e headers
🧪 Testando Nossa API Básica
Inicie sua aplicação com:
mvn quarkus:dev
Agora você pode testar os endpoints:
📋 Listar todos os produtos
curl http://localhost:8080/products
🔍 Buscar produto específico
curl http://localhost:8080/products/1
➕ Criar novo produto
curl -X POST http://localhost:8080/products \
-H "Content-Type: application/json" \
-d '{
"name": "Monitor 4K",
"description": "Monitor ultrawide para programação",
"price": 799.99
}'
✏️ Atualizar produto
curl -X PUT http://localhost:8080/products/1 \
-H "Content-Type: application/json" \
-d '{
"name": "MacBook Pro M3 - Atualizado",
"description": "Versão atualizada com mais RAM",
"price": 3299.99
}'
🗑️ Deletar produto
curl -X DELETE http://localhost:8080/products/1
⚠️ Tratamento de Erros e Exceções
Agora que temos nossa API básica funcionando, precisamos torná-la robusta com um sistema profissional de tratamento de erros. Uma API robusta deve lidar graciosamente com situações inesperadas.
1. Exceções Personalizadas
Crie exceções específicas para seu domínio:
package com.example.exception;
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(Long id) {
super("Produto com ID " + id + " não foi encontrado");
}
}
package com.example.exception;
public class InvalidProductDataException extends RuntimeException {
public InvalidProductDataException(String message) {
super(message);
}
}
2. Classe de Resposta de Erro Padronizada
package com.example.exception;
import java.time.LocalDateTime;
public class ErrorResponse {
public String code;
public String message;
public LocalDateTime timestamp;
public String path;
public ErrorResponse(String code, String message) {
this.code = code;
this.message = message;
this.timestamp = LocalDateTime.now();
}
public ErrorResponse(String code, String message, String path) {
this(code, message);
this.path = path;
}
}
3. Mapeadores de Exceção Globais
package com.example.exception;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class ProductNotFoundExceptionMapper implements ExceptionMapper<ProductNotFoundException> {
@Override
public Response toResponse(ProductNotFoundException exception) {
return Response.status(Response.Status.NOT_FOUND)
.entity(new ErrorResponse("PRODUCT_NOT_FOUND", exception.getMessage()))
.build();
}
}
package com.example.exception;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class GeneralExceptionMapper implements ExceptionMapper<RuntimeException> {
@Override
public Response toResponse(RuntimeException exception) {
// Log da exceção para monitoramento
System.err.println("Erro interno: " + exception.getMessage());
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(new ErrorResponse("INTERNAL_ERROR", "Erro interno do servidor"))
.build();
}
}
4. Usando as Exceções no Resource
Agora podemos atualizar nosso resource para usar o sistema de tratamento de erros:
@GET
@Path("/{id}")
public Product getProductById(@PathParam("id") Long id) {
return products.stream()
.filter(p -> p.id.equals(id))
.findFirst()
.orElseThrow(() -> new ProductNotFoundException(id));
}
@POST
public Response createProduct(Product product) {
// Validação de negócio usando nossa exceção personalizada
if (products.stream().anyMatch(p -> p.name.equals(product.name))) {
throw new InvalidProductDataException("Já existe um produto com este nome");
}
product.id = idGenerator.incrementAndGet();
products.add(product);
return Response.status(Response.Status.CREATED).entity(product).build();
}
Agora nossa API tem um sistema de tratamento de erros consistente e profissional! 🛡️
🛡️ Validação de Dados: Sua Primeira Linha de Defesa
Com o tratamento de erros implementado, podemos adicionar validação robusta que usa essas estruturas. APIs robustas nunca confiam nos dados que recebem!
Adicionando Bean Validation
Primeiro, adicione a extensão de validação:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
Modelo com Validações
Atualize sua classe Product:
package com.example.model;
import jakarta.validation.constraints.*;
public class Product {
public Long id;
@NotBlank(message = "Nome é obrigatório")
@Size(min = 2, max = 100, message = "Nome deve ter entre 2 e 100 caracteres")
public String name;
@Size(max = 500, message = "Descrição não pode exceder 500 caracteres")
public String description;
@NotNull(message = "Preço é obrigatório")
@DecimalMin(value = "0.0", inclusive = false, message = "Preço deve ser maior que zero")
@Digits(integer = 10, fraction = 2, message = "Preço deve ter no máximo 2 casas decimais")
public Double price;
public Product() {}
public Product(Long id, String name, String description, Double price) {
this.id = id;
this.name = name;
this.description = description;
this.price = price;
}
}
Ativando Validação nos Endpoints
Adicione @Valid
nos métodos que recebem dados:
@POST
public Response createProduct(@Valid Product product) {
product.id = idGenerator.incrementAndGet();
products.add(product);
return Response.status(Response.Status.CREATED).entity(product).build();
}
@PUT
@Path("/{id}")
public Response updateProduct(@PathParam("id") Long id, @Valid Product updatedProduct) {
// ... resto do código
}
Personalizando Mensagens de Validação
Para ter controle total sobre as mensagens, você pode criar um tratador de exceções de validação que usa nossa estrutura de erro:
package com.example.exception;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
import java.util.Set;
import java.util.stream.Collectors;
@Provider
public class ValidationExceptionMapper implements ExceptionMapper<ConstraintViolationException> {
@Override
public Response toResponse(ConstraintViolationException exception) {
Set<ConstraintViolation<?>> violations = exception.getConstraintViolations();
String errors = violations.stream()
.map(violation -> {
// Extrai o último nome do caminho da propriedade
String path = violation.getPropertyPath().toString();
String field = path.contains(".") ? path.substring(path.lastIndexOf('.') + 1) : path;
return String.format("%s: %s", field, violation.getMessage());
})
.distinct()
.collect(Collectors.joining(", "));
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("VALIDATION_ERROR", "Dados inválidos: " + errors))
.build();
}
}
Testando Validação
Tente criar um produto inválido:
curl -X POST http://localhost:8080/products \
-H "Content-Type: application/json" \
-d '{
"name": "",
"price": -10
}'
Resposta esperada (Status: 400 Bad Request):
{
"code":"VALIDATION_ERROR",
"message":"Dados inválidos: price: Preço deve ser maior que zero, name: Nome deve ter entre 2 e 100 caracteres, name: Nome é obrigatório",
"timestamp":"2025-06-17T21:54:22.5735566"
}
Agora nossa API tem validação consistente usando nosso sistema de tratamento de erros! 🎨
🎨 Suporte a Múltiplos Formatos
Embora JSON seja o padrão, às vezes você precisa suportar XML ou outros formatos. Com nossa base sólida de tratamento de erros e validação, podemos facilmente adicionar múltiplos formatos:
Para XML
Adicione a dependência JAXB:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jaxb</artifactId>
</dependency>
Crie uma classe wrapper anotada com @XmlRootElement para encapsular a lista de produtos
package com.example.model;
import jakarta.xml.bind.annotation.XmlElement;
import jakarta.xml.bind.annotation.XmlRootElement;
import java.util.List;
@XmlRootElement(name = "products")
public class ProductList {
private List<Product> products;
public ProductList() {
// necessário para JAXB
}
public ProductList(List<Product> products) {
this.products = products;
}
@XmlElement(name = "product")
public List<Product> getProducts() {
return products;
}
public void setProducts(List<Product> products) {
this.products = products;
}
}
Adicione anotações JAXB no Modelo
package com.example.model;
import jakarta.xml.bind.annotation.XmlRootElement;
import jakarta.validation.constraints.*;
@XmlRootElement // Necessário para XML
public class Product {
public Long id;
@NotBlank(message = "Nome é obrigatório")
@Size(min = 2, max = 100, message = "Nome deve ter entre 2 e 100 caracteres")
public String name;
@Size(max = 500, message = "Descrição não pode exceder 500 caracteres")
public String description;
@NotNull(message = "Preço é obrigatório")
@DecimalMin(value = "0.0", inclusive = false, message = "Preço deve ser maior que zero")
public Double price;
// ... construtores
}
Atualize as Anotações do Resource
@Produces({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
@Consumes({MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML})
public class ProductResource {
@GET
public ProductList getAllProducts() {
return new ProductList(products);
}
// ... os outros métodos permanecem iguais
}
Testando XML
Receber resposta em XML:
curl -H "Accept: application/xml" http://localhost:8080/products/1
Resposta esperada:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<product>
<id>1</id>
<name>MacBook Pro M3</name>
<description>Laptop profissional para desenvolvimento</description>
<price>2999.99</price>
</product>
Enviar dados em XML:
curl -X POST http://localhost:8080/products \
-H "Content-Type: application/xml" \
-d '<?xml version="1.0" encoding="UTF-8"?>
<product>
<name>Produto via XML</name>
<description>Criado usando XML</description>
<price>99.99</price>
</product>'
🎭 Negociação de Conteúdo Automática
O RESTEasy faz negociação de conteúdo automaticamente baseada nos headers:
-
Accept: application/json
→ Resposta em JSON -
Accept: application/xml
→ Resposta em XML -
Accept: */*
→ JSON (padrão) -
Content-Type: application/json
→ Lê JSON -
Content-Type: application/xml
→ Lê XML
Outros Formatos Suportados
Para YAML (adicione quarkus-resteasy-reactive-yaml
):
@Produces({MediaType.APPLICATION_JSON, "application/yaml"})
Para texto simples:
@GET
@Path("/info")
@Produces(MediaType.TEXT_PLAIN)
public String getInfo() {
return "API de Produtos v1.0 - Total de produtos: " + products.size();
}
🚀 Dicas Pro para APIs de Produção
Com nossa base sólida (CRUD + tratamento de erros + validação + múltiplos formatos), podemos implementar recursos avançados para produção:
1. Paginação para Listas Grandes
Quando você tem milhares de produtos, retornar todos de uma vez não é viável:
@GET
public Response getAllProducts(
@QueryParam("page") @DefaultValue("0") int page,
@QueryParam("size") @DefaultValue("10") int size,
@QueryParam("sort") @DefaultValue("id") String sortBy) {
// Validação básica
if (page < 0 || size < 1 || size > 100) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(new ErrorResponse("INVALID_PARAMS", "Parâmetros de paginação inválidos"))
.build();
}
// Simulando paginação (em um cenário real, isso viria do banco)
int startIndex = page * size;
int endIndex = Math.min(startIndex + size, products.size());
List<Product> pageProducts = products.subList(startIndex, endIndex);
return Response.ok(pageProducts)
.header("X-Total-Count", products.size())
.header("X-Page", page)
.header("X-Page-Size", size)
.build();
}
Testando paginação:
# Primeira página (produtos 0-2)
curl "http://localhost:8080/products?page=0&size=2"
# Segunda página (produto 3)
curl "http://localhost:8080/products?page=1&size=2"
2. Filtros e Busca
Permita que os usuários encontrem o que procuram:
@GET
@Path("/search")
public Response searchProducts(
@QueryParam("name") String name,
@QueryParam("minPrice") Double minPrice,
@QueryParam("maxPrice") Double maxPrice) {
return Response.ok(products.stream()
.filter(p -> name == null || p.name.toLowerCase().contains(name.toLowerCase()))
.filter(p -> minPrice == null || p.price >= minPrice)
.filter(p -> maxPrice == null || p.price <= maxPrice)
.collect(Collectors.toList()))
.build();
}
Exemplos de uso:
# Buscar produtos com "mac" no nome
curl "http://localhost:8080/products/search?name=mac"
# Produtos entre R$ 50 e R$ 200
curl "http://localhost:8080/products/search?minPrice=50&maxPrice=200"
# Combinar filtros
curl "http://localhost:8080/products/search?name=mouse&maxPrice=100"
3. Headers Úteis para Performance
@GET
@Path("/{id}")
public Response getProductById(@PathParam("id") Long id) {
Optional<Product> product = findProductById(id);
if (product.isPresent()) {
return Response.ok(product.get())
// Cache por 5 minutos
.header("Cache-Control", "max-age=300")
// ETag para versionamento
.header("ETag", "\"" + product.get().hashCode() + "\"")
// CORS headers
.header("Access-Control-Allow-Origin", "*")
.build();
}
throw new ProductNotFoundException(id);
}
4. Documentação com OpenAPI/Swagger
Adicione documentação automática:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-smallrye-openapi</artifactId>
</dependency>
Anote seus endpoints se quiser personalizar:
import org.eclipse.microprofile.openapi.annotations.Operation;
import org.eclipse.microprofile.openapi.annotations.responses.ApiResponse;
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
@Tag(name = "Produtos", description = "Operações relacionadas a produtos")
@Path("/products")
public class ProductResource {
@GET
@Operation(summary = "Listar todos os produtos",
description = "Retorna uma lista de todos os produtos disponíveis")
@APIResponse(responseCode = "200", description = "Lista de produtos retornada com sucesso")
public List<Product> getAllProducts() {
return products;
}
@POST
@Operation(summary = "Criar novo produto",
description = "Cria um novo produto com os dados fornecidos")
@APIResponse(responseCode = "201", description = "Produto criado com sucesso")
@APIResponse(responseCode = "400", description = "Dados inválidos fornecidos")
public Response createProduct(@Valid Product product) {
// ... implementação
}
}
Acesse a documentação:
- Swagger UI: http://localhost:8080/q/swagger-ui/
- OpenAPI JSON: http://localhost:8080/q/openapi
5. Versionamento de API
Quando sua API evolui, você precisa manter compatibilidade:
Opção 1: Versionamento por URL
@Path("/v1/products")
public class ProductResourceV1 {
// Versão antiga da API
}
@Path("/v2/products")
public class ProductResourceV2 {
// Nova versão com recursos adicionais
}
Opção 2: Versionamento por Header
@GET
@Path("/products")
public Response getProducts(@HeaderParam("API-Version") String version) {
if ("v2".equals(version)) {
// Lógica da v2
return Response.ok(enhancedProducts).build();
}
// Lógica padrão (v1)
return Response.ok(products).build();
}
6. Rate Limiting Básico
Para APIs públicas, implemente limitação de taxa:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Path("/products")
public class ProductResource {
private final Map<String, AtomicInteger> requestCounts = new ConcurrentHashMap<>();
private final int MAX_REQUESTS_PER_MINUTE = 100;
@GET
public Response getAllProducts(@Context HttpServletRequest request) {
String clientIp = request.getRemoteAddr();
// Verificar rate limit (implementação simplificada)
AtomicInteger count = requestCounts.computeIfAbsent(clientIp, k -> new AtomicInteger(0));
if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) {
return Response.status(429) // Too Many Requests
.entity(new ErrorResponse("RATE_LIMIT_EXCEEDED", "Muitas requisições. Tente novamente em 60 segundos."))
.header("Retry-After", "60")
.build();
}
return Response.ok(products).build();
}
}
🎯 Resumo do Capítulo
Parabéns! Você acabou de dominar a criação de APIs RESTful com Quarkus! 🎉
O que você conquistou:
✅ Entendeu os fundamentos de REST e JAX-RS
✅ Criou endpoints completos (CRUD)
✅ Implementou tratamento robusto de erros
✅ Adicionou validação profissional de dados
✅ Aprendeu sobre múltiplos formatos (JSON/XML)
✅ Descobriu dicas avançadas para APIs de produção
Sua API já está funcional e pronta para o mundo real! Mas ainda temos muito a explorar...
🔗 Continue a Jornada
👉 Capítulo 6: Persistência de Dados com Panache (Hibernate ORM) - Vamos conectar nossa API a um banco de dados utilizando Panache
💬 Vamos Conversar!
Gostou do capítulo? Tem alguma dúvida sobre APIs RESTful?
- 👍 Curta se foi útil
- 💬 Comente suas dúvidas ou experiências
- 🔔 Siga para não perder os próximos capítulos
- 🔄 Compartilhe com outros devs
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.