diff --git a/.gitignore b/.gitignore index ab7bda3..3c4fee3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,8 @@ target/ -*.class -*.jar -*.war -*.ear -.idea/ -.vscode/ -*.iml -.project -.classpath -.settings/ -.DS_Store -Thumbs.db -*.log -*.tmp +logs/ +*.duckdb + +# Frontend +portability-frontend/node_modules/ +portability-frontend/dist/ +.angular/ diff --git a/Dockerfile-api b/Dockerfile-api new file mode 100644 index 0000000..8cfe845 --- /dev/null +++ b/Dockerfile-api @@ -0,0 +1,35 @@ +# Fase 2 — Imagem da API REST hexagonal (Spring Boot 3.3 / Java 21) +# ---- Stage 1: build ---- +FROM maven:3.9-eclipse-temurin-21 AS build +WORKDIR /build + +# Copia os POMs do multi-módulo primeiro para aproveitar o cache do Docker +COPY pom.xml . +COPY portability-core/pom.xml portability-core/ +COPY portability-adapters/pom.xml portability-adapters/ +COPY portability-batch/pom.xml portability-batch/ +COPY portability-api/pom.xml portability-api/ +RUN mvn -B -q dependency:go-offline || true + +# Copia o código-fonte e compila (boot jar do módulo portability-api) +COPY portability-core/src portability-core/src +COPY portability-adapters/src portability-adapters/src +COPY portability-batch/src portability-batch/src +COPY portability-api/src portability-api/src +RUN mvn -B -q clean package -DskipTests + +# ---- Stage 2: runtime ---- +FROM eclipse-temurin:21-jre +WORKDIR /app + +COPY --from=build /build/portability-api/target/portability-api-*-boot.jar /app/portability-api.jar + +# Dicionários/prefixos embarcados; a base DuckDB é montada como volume. +COPY Operadoras.csv /app/Operadoras.csv +COPY Operadoras_completo.csv /app/Operadoras_completo.csv +COPY prefixos_operadora.json /app/prefixos_operadora.json + +VOLUME ["/app/saida", "/app/logs"] +EXPOSE 8080 + +CMD ["java", "-jar", "/app/portability-api.jar"] diff --git a/PLANO_MELHORIAS.md b/PLANO_MELHORIAS.md index cf4c24c..c169d4f 100644 --- a/PLANO_MELHORIAS.md +++ b/PLANO_MELHORIAS.md @@ -84,20 +84,24 @@ Objetivo: alinhar toolchain e criar rede de segurança antes de refatorar. **Resultado:** `mvn clean verify` verde — 55 testes (34 core + 11 adapters/benchmark + ... incluindo batch), Checkstyle + JaCoCo ok, benchmark automatizado como gate de regressão de performance (<30s; atual ~1,2s). -### Fase 2 — API REST com arquitetura hexagonal (~2–3 semanas) -- [ ] Módulo `api` (Spring Boot 3.x, Java 21): - - `POST /api/v1/portabilidade/consultar` — 1..N números (sync, limitado) - - `POST /api/v1/portabilidade/batch` — upload multipart CSV/XLSX → retorna job ID +### Fase 2 — API REST com arquitetura hexagonal (~2–3 semanas) ✅ CONCLUÍDA +- [x] Módulo `portability-api` (Spring Boot 3.3, Java 21, boot jar `-boot.jar`): + - `POST /api/v1/portabilidade/consultar` — 1..N números (sync, máx. 1000, deduplicados) + - `POST /api/v1/portabilidade/batch` — upload multipart CSV/XLSX → retorna job ID (`202`) - `GET /api/v1/portabilidade/batch/{id}` — status/progresso - - `GET /api/v1/portabilidade/batch/{id}/resultado` — download -- [ ] Respeitar a hexagonal na prática: - - `application/` = use cases (`ConsultPortabilityUseCase`, `SubmitBatchUseCase`) dependendo só de ports - - `infrastructure/web` = controllers + DTOs/mappers; `infrastructure/persistence` = adapter DuckDB existente - - Composição via `@Configuration` (elimina DI manual do `main`) -- [ ] Contratos: **OpenAPI 3** (`springdoc-openapi`) + versões de API (`/v1`), erros padronizados (**RFC 7807 ProblemDetails**) -- [ ] Validação de entrada (`jakarta.validation`), limites de payload, deduplicação de números na request -- [ ] **Cache**: resultados de portabilidade são relativamente estáveis → Caffeine (L1, hot numbers) com TTL configurável; avaliar Redis (L2) para múltiplas réplicas -- [ ] Testes: contrato (`@WebMvcTest`), integração com DuckDB embarcado, carga básica (k6/Gatling) — meta p95 < 50 ms single lookup com cache + - `GET /api/v1/portabilidade/batch/{id}/resultado` — download CSV +- [x] Hexagonal na prática: + - `application/usecase` = `ConsultPortabilityUseCase`, `SubmitBatchUseCase` dependendo só de ports + - `infrastructure/web` = controllers + DTOs/mappers; `infrastructure/repository` = adapter DuckDB + cache; `infrastructure/batch` = executor de jobs + - Composição via `@Configuration`/Spring DI +- [x] Contratos: **OpenAPI 3** (`springdoc-openapi`, `/v3/api-docs` + Swagger UI), versionamento `/v1`, erros **RFC 7807 ProblemDetails** +- [x] Validação de entrada (`jakarta.validation`), limites de payload (multipart 100 MB), deduplicação de números +- [x] **Cache**: Caffeine L1 com TTL configurável (Redis L2 avaliado na Fase 4) +- [x] Testes: web layer (`MockMvc` standalone), use cases unitários, integração batch E2E — build verde com 70 testes no total +- [x] Virtual threads habilitadas (`spring.threads.virtual.enabled=true`) + compressão gzip +- [x] Docs/entrega: seção "API REST" no README, `Dockerfile-api` + serviço `portability-api` no docker-compose + +**Resultado:** `mvn clean verify` BUILD SUCCESS — 70 testes (51 legados + 19 da API), Checkstyle/JaCoCo ok, smoke test do boot jar validado (endpoint consultar + swagger). ### Fase 3 — BFF + Frontend Angular 17+ (~2–4 semanas) - [ ] Módulo **`bff`** (Spring Boot dedicado ao frontend): diff --git a/README.md b/README.md index e1fc03f..318c3dc 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,17 @@ portabilidade-java/ # Parent POM (multi-módulo, Java 21) │ ├── output/ (CsvOutputWriter) │ ├── repository/ (DuckDbPortabilityRepository) │ └── resolver/ (JsonPrefixResolver) -└── portability-batch/ # ← Camada de entrada (main + orquestração) +├── portability-batch/ # ← Camada de entrada (main + orquestração) +│ └── src/main/java/com/portabilidade/ +│ ├── PortabilityApplication.java # Ponto de entrada (main) +│ └── application/PortabilityBatchProcessor.java +└── portability-api/ # ← API REST hexagonal (Spring Boot 3.3 / Java 21) └── src/main/java/com/portabilidade/ - ├── PortabilityApplication.java # Ponto de entrada (main) - └── application/PortabilityBatchProcessor.java + ├── application/usecase/ # Casos de uso (ConsultPortability, SubmitBatch) + └── infrastructure/ + ├── web/ # Controllers, DTOs, RFC 7807, OpenAPI + ├── repository/ # Adapter DuckDB + cache Caffeine + └── batch/ # Executor de jobs de batch (multipart CSV/XLSX) ``` --- @@ -339,6 +346,42 @@ Para usar um dicionário de operadoras externo, basta colocar um --- +## API REST (Fase 2 — Spring Boot 3.3 / Java 21) + +O módulo `portability-api` expõe a mesma lógica de negócio do batch como serviço HTTP, +com arquitetura hexagonal (use cases no centro, adapters de web/persistência nas bordas). + +### Executar + +```bash +mvn -pl portability-api -am package +java -jar portability-api/target/portability-api-1.0.0-boot.jar +# porta padrão: 8080 | Swagger UI: http://localhost:8080/swagger-ui.html +``` + +### Endpoints + +| Método | Rota | Descrição | +|--------|------|-----------| +| `POST` | `/api/v1/portabilidade/consultar` | Consulta 1..N números (sync, máx. 1000 por request, deduplicados, cache Caffeine) | +| `POST` | `/api/v1/portabilidade/batch` | Upload multipart CSV/XLSX → retorna job ID (`202 Accepted`) | +| `GET` | `/api/v1/portabilidade/batch/{id}` | Status/progresso do job | +| `GET` | `/api/v1/portabilidade/batch/{id}/resultado` | Download do CSV de resultado | + +Exemplo: + +```bash +curl -s localhost:8080/api/v1/portabilidade/consultar \ + -H 'Content-Type: application/json' \ + -d '{"numeros":["11987654321","2125551234"]}' +``` + +Erros seguem **RFC 7807 ProblemDetails** (`application/problem+json`) e o contrato +OpenAPI 3 está em `/v3/api-docs`. Requisições são atendidas em **virtual threads** +(Java 21) com compressão gzip habilitada. + +--- + ## Solução de problemas | Sintoma | Causa provável | Solução | diff --git a/docker-compose.yml b/docker-compose.yml index 9e08ba9..9ba3bad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,3 +20,21 @@ services: - ./prefixos_operadora.json:/app/prefixos_operadora.json:ro # Roda o batch uma vez e encerra (--rm remove o container ao final) restart: "no" + + # Fase 2: API REST hexagonal (Spring Boot 3.3 / Java 21) — porta 8080 + portability-api: + build: + context: . + dockerfile: Dockerfile-api + image: portability-api:latest + container_name: portability-api + ports: + - "8080:8080" + volumes: + - ./base_portabilidade:/app/base_portabilidade + - ./saida:/app/saida + - ./logs:/app/logs + - ./Operadoras.csv:/app/Operadoras.csv:ro + - ./Operadoras_completo.csv:/app/Operadoras_completo.csv:ro + - ./prefixos_operadora.json:/app/prefixos_operadora.json:ro + restart: unless-stopped diff --git a/logs/portabilidade.log b/logs/portabilidade.log new file mode 100644 index 0000000..9849258 --- /dev/null +++ b/logs/portabilidade.log @@ -0,0 +1,17 @@ +2026-10-31 01:27:58.031 [main] INFO c.p.PortabilityApplication - Portabilidade Numérica Batch — iniciando +2026-10-31 01:27:58.035 [main] INFO c.p.application.config.AppPaths - BASE_DIR resolvido: /workspace/portability-batch/target +2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - Caminhos configurados: +2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - entrada/ : /workspace/portability-batch/target/entrada +2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - saida/ : /workspace/portability-batch/target/saida +2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - DuckDB : /workspace/portability-batch/target/base_portabilidade/portabilidade.duckdb +2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - Operadoras.csv : /workspace/portability-batch/target/Operadoras.csv +2026-10-31 01:27:58.037 [main] INFO c.p.PortabilityApplication - prefixos JSON : /workspace/portability-batch/target/prefixos_operadora.json +2026-10-31 01:27:58.038 [main] WARN c.p.i.loader.CsvOperatorLoader - Arquivo Operadoras.csv não encontrado em: /workspace/portability-batch/target/Operadoras.csv +2026-10-31 01:27:58.038 [main] INFO c.p.PortabilityApplication - Dicionário de operadoras (exemplo): 0 entradas +2026-10-31 01:27:58.038 [main] WARN c.p.PortabilityApplication - Nenhum dicionário completo de operadoras encontrado — usando o de exemplo (baixa cobertura). +2026-10-31 01:27:58.329 [main] WARN c.p.i.loader.JsonPrefixMapLoader - Arquivo de prefixos não encontrado em: /workspace/portability-batch/target/prefixos_operadora.json +2026-10-31 01:27:58.330 [main] INFO c.p.PortabilityApplication - Mapa de prefixos: 0 entradas +2026-10-31 01:27:58.334 [main] INFO c.p.a.PortabilityBatchProcessor - === Portabilidade Numérica Batch - Iniciando === +2026-10-31 01:27:58.340 [main] WARN c.p.infrastructure.file.FileScanner - Nenhum arquivo .csv ou .xlsx encontrado em: /workspace/portability-batch/target/entrada +2026-10-31 01:27:58.341 [main] WARN c.p.a.PortabilityBatchProcessor - Nenhum arquivo encontrado em '/workspace/portability-batch/target/entrada'. Encerrando. +2026-10-31 01:27:58.341 [main] INFO c.p.PortabilityApplication - Portabilidade Numérica Batch — concluído com sucesso diff --git a/pom.xml b/pom.xml index 3f08511..34c773b 100644 --- a/pom.xml +++ b/pom.xml @@ -24,8 +24,8 @@ portability-core portability-adapters portability-batch - - + portability-api + portability-bff @@ -44,6 +44,7 @@ 5.12.0 0.8.12 + 2.6.0 10.17.0 3.5.0 3.3.1 @@ -138,6 +139,8 @@ ${maven.compiler.release} UTF-8 + + true diff --git a/portability-adapters/src/main/java/com/portabilidade/infrastructure/output/CsvOutputWriter.java b/portability-adapters/src/main/java/com/portabilidade/infrastructure/output/CsvOutputWriter.java index 4607fa8..c4daf7c 100644 --- a/portability-adapters/src/main/java/com/portabilidade/infrastructure/output/CsvOutputWriter.java +++ b/portability-adapters/src/main/java/com/portabilidade/infrastructure/output/CsvOutputWriter.java @@ -78,20 +78,51 @@ public StreamingCsvWriter open(String originalName, String outputDir) throws IOE } /** Writer de CSV em streaming: grava linha a linha, header automático. */ - public static final class StreamingCsvWriter implements Closeable { + public static class StreamingCsvWriter implements Closeable { private final Path outputPath; - private final BufferedWriter writer; - private String[] headers; // definidos na primeira linha + final BufferedWriter writer; + String[] headers; private long rowsWritten; private boolean closed; StreamingCsvWriter(Path outputPath) throws IOException { + this(outputPath, true); + } + + /** + * Construtor protegido para uso por subclasses em outros módulos (API/Fase 2). + * @param withBom BOM UTF-8 apenas para outputs "Excel-friendly" do batch legado; + * resultados de API usam CSV puro. + */ + public StreamingCsvWriter(Path outputPath, boolean withBom) throws IOException { this.outputPath = outputPath; // UTF-8 com BOM para compatibilidade com Excel this.writer = new BufferedWriter( new OutputStreamWriter(new FileOutputStream(outputPath.toFile()), StandardCharsets.UTF_8), WRITE_BUFFER_BYTES); - this.writer.write('\uFEFF'); + if (withBom) { + this.writer.write('\uFEFF'); + } + } + + /** Escreve um header fixo antes da primeira linha (usado pela API — Fase 2). */ + public void writeHeader(List columns) throws IOException { + if (headers != null || rowsWritten > 0) { + throw new IllegalStateException("header ja escrito"); + } + headers = columns.toArray(new String[0]); + StringBuilder hl = new StringBuilder(); + for (int i = 0; i < headers.length; i++) { + if (i > 0) hl.append(SEPARATOR); + hl.append(escapeField(headers[i])); + } + writer.write(hl.toString()); + writer.newLine(); + } + + /** Libera o buffer para o disco sem fechar o arquivo (progresso visível durante execução). */ + public void flush() throws IOException { + writer.flush(); } public void writeRow(Map row) throws IOException { diff --git a/portability-adapters/src/main/java/com/portabilidade/infrastructure/repository/DuckDbDatabaseProvisioner.java b/portability-adapters/src/main/java/com/portabilidade/infrastructure/repository/DuckDbDatabaseProvisioner.java new file mode 100644 index 0000000..6fef7ee --- /dev/null +++ b/portability-adapters/src/main/java/com/portabilidade/infrastructure/repository/DuckDbDatabaseProvisioner.java @@ -0,0 +1,100 @@ +package com.portabilidade.infrastructure.repository; + + + +import java.io.BufferedWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; + +/** + * Provisiona a base DuckDB de portabilidade (Fase 1/2 do PLANO_MELHORIAS). + * + * Uso: java -cp ... DuckDbDatabaseProvisioner <base_dir> <arquivo_portabilidade.csv> [out.duckdb] + * + * O arquivo de origem deve ter colunas telefone (BIGINT) e rn1 (VARCHAR), + * separadas por ; ou , (detectado automaticamente). A carga é feita via + * COPY + JOIN contra um CSV gerado com tipos normalizados, e a tabela final + * já sai com índice em telefone (query path read-only nunca escreve). + */ +public final class DuckDbDatabaseProvisioner { + + private DuckDbDatabaseProvisioner() { + } + + public static void main(String[] args) throws Exception { + if (args.length < 2) { + System.err.println("Uso: DuckDbDatabaseProvisioner [saida.duckdb]"); + System.exit(2); + } + Path baseDir = Path.of(args[0]); + Path source = Path.of(args[1]); + Path out = args.length > 2 ? baseDir.resolve(args[2]) + : baseDir.resolve("portabilidade.duckdb"); + Files.createDirectories(out.getParent()); + long t0 = System.currentTimeMillis(); + long rows = provision(source, out); + System.out.printf("Base provisionada: %s (%d linhas, %d ms)%n", + out, rows, System.currentTimeMillis() - t0); + } + + /** Cria (sobrescrevendo) a base portabilidade(telefone BIGINT, rn1 VARCHAR) + índice. */ + public static long provision(Path sourceCsv, Path duckdbFile) throws Exception { + String sep = detectSeparator(sourceCsv); + Path tmp = Files.createTempFile("prov_portabilidade_", ".csv"); + try (BufferedWriter w = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) { + normalizeToCsv(sourceCsv, sep, w); + } + try (Connection c = DriverManager.getConnection("jdbc:duckdb:" + duckdbFile); + Statement st = c.createStatement()) { + // duckdb_jdbc 1.1.x: o separador é passado por nome ('sep'), nao 'delimiter'. + st.execute("CREATE TABLE portabilidade AS " + + "SELECT CAST(telefone AS BIGINT) AS telefone, rn1 " + + "FROM read_csv('" + tmp.toAbsolutePath().toString().replace("'", "''") + + "', sep=',', header=true)"); + st.execute("CREATE INDEX IF NOT EXISTS idx_port_telefone ON portabilidade(telefone)"); + var rs = st.executeQuery("SELECT count(*) FROM portabilidade"); + rs.next(); + long rows = rs.getLong(1); + c.close(); + return rows; + } finally { + Files.deleteIfExists(tmp); + } + } + + private static String detectSeparator(Path csv) throws Exception { + try (var lines = Files.lines(csv, StandardCharsets.UTF_8)) { + String first = lines.findFirst().orElse(","); + int semi = first.indexOf(';'), comma = first.indexOf(','); + return (semi >= 0 && semi < comma) || comma < 0 ? ";" : ","; + } + } + + /** Copia telefone/rn1 (colunas 0 e 1 após header) escapando aspas duplas. */ + private static void normalizeToCsv(Path sourceCsv, String sep, BufferedWriter w) throws Exception { + w.write("telefone,rn1\n"); + try (var r = Files.newBufferedReader(sourceCsv, StandardCharsets.UTF_8)) { + String line; + boolean header = true; + while ((line = r.readLine()) != null) { + if (header) { header = false; continue; } + if (line.isBlank()) continue; + int idx = line.indexOf(sep); + if (idx <= 0) continue; + String tel = line.substring(0, idx).trim(); + String rn1 = line.substring(idx + sep.length()).trim(); + if (!tel.matches("\\d+")) continue; + w.write(tel); + w.write(','); + w.write('"'); + w.write(rn1.replace("\"", "\"\"")); + w.write('"'); + w.write('\n'); + } + } + } +} diff --git a/portability-api/pom.xml b/portability-api/pom.xml new file mode 100644 index 0000000..21a96fb --- /dev/null +++ b/portability-api/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + + com.portabilidade + portabilidade-parent + 1.0.0 + + + portability-api + Portabilidade API (Fase 2 - Spring Boot / hexagonal) + + + + + + com.portabilidade + portability-core + + + com.portabilidade + portability-adapters + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + com.github.ben-manes.caffeine + caffeine + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + boot + + + + + + + diff --git a/portability-api/src/main/java/com/portabilidade/PortabilityApiApplication.java b/portability-api/src/main/java/com/portabilidade/PortabilityApiApplication.java new file mode 100644 index 0000000..d1ef334 --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/PortabilityApiApplication.java @@ -0,0 +1,20 @@ +package com.portabilidade; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Fase 2 (PLANO_MELHORIAS): API REST hexagonal — Spring Boot 3.3 / Java 21. + * + * Camadas (dependências apontam para dentro): + * infrastructure/web → application/usecases → ports (core) + * A composição de dependências é feita em {@code CoreConfig} (@Configuration), + * eliminando a DI manual do main do batch. + */ +@SpringBootApplication +public class PortabilityApiApplication { + + public static void main(String[] args) { + SpringApplication.run(PortabilityApiApplication.class, args); + } +} diff --git a/portability-api/src/main/java/com/portabilidade/application/usecase/ConsultPortabilityUseCase.java b/portability-api/src/main/java/com/portabilidade/application/usecase/ConsultPortabilityUseCase.java new file mode 100644 index 0000000..9c04377 --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/application/usecase/ConsultPortabilityUseCase.java @@ -0,0 +1,52 @@ +package com.portabilidade.application.usecase; + +import com.portabilidade.service.PhoneNormalizer; +import com.portabilidade.service.PortabilityResolutionService; +import com.portabilidade.service.ProcessResult; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * Use case síncrono: consulta 1..N números e resolve operadora/portadora. + * + * Hexagonal: depende apenas do serviço do core (que por sua vez só conhece os + * ports PortabilityRepository/PrefixResolver). Sem conhecimento de HTTP/DTOs — + * o mapper fica em infrastructure/web. + */ +@Service +public class ConsultPortabilityUseCase { + + /** Limite duro de números por request síncrona (mitigação de payload abusivo). */ + public static final int MAX_SYNC_NUMBERS = 100; + + private final PortabilityResolutionService resolutionService; + private final PhoneNormalizer normalizer; + + public ConsultPortabilityUseCase(PortabilityResolutionService resolutionService, + PhoneNormalizer normalizer) { + this.resolutionService = resolutionService; + this.normalizer = normalizer; + } + + /** + * @param rawNumbers números brutos (1..MAX_SYNC_NUMBERS) + * @return resultado posicional do core (raw/operators/sources na mesma ordem) + * @throws IllegalArgumentException se vazio ou acima do limite + */ + public ProcessResult consult(List rawNumbers) { + if (rawNumbers == null || rawNumbers.isEmpty()) { + throw new IllegalArgumentException("lista de numeros vazia"); + } + if (rawNumbers.size() > MAX_SYNC_NUMBERS) { + throw new IllegalArgumentException( + "maximo de " + MAX_SYNC_NUMBERS + " numeros por consulta sincrona; use o endpoint de batch"); + } + return resolutionService.processNumbers(rawNumbers); + } + + /** Normalização canônica (10/11 dígitos) exposta para o mapper de resposta. */ + public String canonical(String raw) { + return normalizer.getCleanDigits(raw); + } +} diff --git a/portability-api/src/main/java/com/portabilidade/application/usecase/SubmitBatchUseCase.java b/portability-api/src/main/java/com/portabilidade/application/usecase/SubmitBatchUseCase.java new file mode 100644 index 0000000..e78bb0c --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/application/usecase/SubmitBatchUseCase.java @@ -0,0 +1,51 @@ +package com.portabilidade.application.usecase; + +import com.portabilidade.infrastructure.batch.BatchJob; +import org.springframework.stereotype.Service; + +import java.nio.file.Path; +import java.util.Optional; + +/** + * Use case assíncrono: submete um arquivo (CSV/XLSX) para processamento em + * background e expõe consulta de status. Depende apenas dos ports internos + * {@link BatchExecutor} e {@link JobRegistry} — a implementação + * (InMemoryBatchJobStore + virtual threads) é infraestrutura plugável + * (fila durável entra na Fase 4 do PLANO_MELHORIAS). + */ +@Service +public class SubmitBatchUseCase { + + /** Port: aceita um arquivo já persistido e devolve o id do job criado. */ + public interface BatchExecutor { + String submit(Path savedFile, String originalFileName); + } + + /** Port: recuperação do estado do job. */ + public interface JobRegistry { + Optional find(String id); + } + + private final BatchExecutor executor; + private final JobRegistry registry; + + public SubmitBatchUseCase(BatchExecutor executor, JobRegistry registry) { + this.executor = executor; + this.registry = registry; + } + + public String submit(Path savedFile, String originalFileName) { + return executor.submit(savedFile, originalFileName); + } + + public Optional status(String jobId) { + return registry.find(jobId); + } + + /** Erro de domínio: arquivo acima do limite configurado de linhas. */ + public static class TooManyRowsException extends RuntimeException { + public TooManyRowsException(long maxRows) { + super("limite de " + maxRows + " linhas excedido"); + } + } +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/CoreConfig.java b/portability-api/src/main/java/com/portabilidade/infrastructure/CoreConfig.java new file mode 100644 index 0000000..ba1728c --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/CoreConfig.java @@ -0,0 +1,149 @@ +package com.portabilidade.infrastructure; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.portabilidade.application.config.AppPaths; +import com.portabilidade.infrastructure.file.CsvInputReader; +import com.portabilidade.infrastructure.file.ExcelInputReader; +import com.portabilidade.infrastructure.file.InputFileReader; +import com.portabilidade.infrastructure.loader.CsvOperatorLoader; +import com.portabilidade.infrastructure.loader.JsonPrefixMapLoader; +import com.portabilidade.infrastructure.repository.CachedPortabilityRepository; +import com.portabilidade.infrastructure.repository.DuckDbPortabilityRepository; +import com.portabilidade.infrastructure.resolver.JsonPrefixResolver; +import com.portabilidade.service.PhoneNormalizer; +import com.portabilidade.service.PortabilityResolutionService; +import com.portabilidade.service.resolver.PortabilityRepository; +import com.portabilidade.service.resolver.PrefixResolver; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Composition root da API (Fase 2): substitui a DI manual do main do batch por + * @Configuration, mantendo o core 100% reutilizado (mesmos ports/adapters). + */ +@Configuration +public class CoreConfig { + + private static final Logger log = LoggerFactory.getLogger(CoreConfig.class); + + @Value("${app.data.base-dir:${user.dir}}") + private String baseDir; + + @Value("${app.data.duckdb-path:#{null}}") + private String duckdbPath; + + @Value("${app.data.operadores-csv:#{null}}") + private String operadoresCsv; + + @Value("${app.data.operadores-csv-completo:#{null}}") + private String operadoresCompletoCsv; + + @Value("${app.data.prefixos-json:#{null}}") + private String prefixosJson; + + @Value("${app.cache.enabled:true}") + private boolean cacheEnabled; + + @Value("${app.cache.ttl-minutes:360}") + private long cacheTtlMinutes; + + @Value("${app.cache.max-size:500000}") + private long cacheMaxSize; + + private DuckDbPortabilityRepository repositoryRef; + + private AppPaths paths() { + return new AppPaths(baseDir); + } + + private String resolve(String explicit, java.util.function.Function fallback) { + return explicit != null ? explicit : fallback.apply(paths()); + } + + // ── adaptadores de saída (ports do core) ──────────────────────────────── + + /** + * Port de consulta: DuckDB (adapter existente, conexão única — Fase 1) + * decorado pelo cache L1 Caffeine quando habilitado. Resultado de + * portabilidade é estável por horas → TTL alto é seguro e derruba a + * latência de hot numbers para ~O(1) (meta p95 < 50 ms da Fase 2). + */ + @Bean + public PortabilityRepository portabilityRepository() { + String db = resolve(duckdbPath, AppPaths::getDuckDbPath); + log.info("API usando DuckDB em: {}", db); + repositoryRef = new DuckDbPortabilityRepository(db); + if (!cacheEnabled) { + return repositoryRef; + } + Cache l1 = Caffeine.newBuilder() + .maximumSize(cacheMaxSize) + .expireAfterWrite(Duration.ofMinutes(cacheTtlMinutes)) + .recordStats() + .build(); + log.info("Cache L1 Caffeine ativo: maxSize={}, ttl={}min", cacheMaxSize, cacheTtlMinutes); + return new CachedPortabilityRepository(repositoryRef, l1); + } + + @Bean + public PrefixResolver prefixResolver() { + Map prefixMap = + new JsonPrefixMapLoader().load(resolve(prefixosJson, AppPaths::getPrefixosJson)); + log.info("Mapa de prefixos carregado: {} entradas", prefixMap.size()); + return new JsonPrefixResolver(prefixMap); + } + + @Bean + public Map operatorsDict() { + CsvOperatorLoader loader = new CsvOperatorLoader(); + Map dict = new LinkedHashMap<>( + loader.load(resolve(operadoresCsv, AppPaths::getOperadoresCsv))); + String completo = operadoresCompletoCsv != null ? operadoresCompletoCsv : paths().getOperadoresCsvCompleto(); + if (completo != null) { + Map extra = loader.load(completo); + if (!extra.isEmpty()) { + dict = extra; + log.info("Dicionario COMPLETO de operadoras: {} entradas ({})", dict.size(), completo); + } + } + return dict; + } + + // ── serviços do core ───────────────────────────────────────────────────── + + @Bean + public PhoneNormalizer phoneNormalizer() { + return new PhoneNormalizer(); + } + + @Bean + public PortabilityResolutionService portabilityResolutionService(PortabilityRepository repository, + PrefixResolver prefixResolver, + PhoneNormalizer normalizer, + Map operatorsDict) { + return new PortabilityResolutionService(repository, prefixResolver, normalizer, operatorsDict); + } + + @Bean + public List inputFileReaders() { + return List.of(new CsvInputReader(), new ExcelInputReader()); + } + + @PreDestroy + void shutdown() { + if (repositoryRef != null) { + repositoryRef.close(); + log.info("Conexao DuckDB da API encerrada"); + } + } +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/OpenApiConfig.java b/portability-api/src/main/java/com/portabilidade/infrastructure/OpenApiConfig.java new file mode 100644 index 0000000..9e5a45c --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/OpenApiConfig.java @@ -0,0 +1,38 @@ +package com.portabilidade.infrastructure; + +import com.portabilidade.application.usecase.ConsultPortabilityUseCase; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * OpenAPI 3 (springdoc) — documentação viva em /swagger-ui.html (Fase 2). + */ +@Configuration +public class OpenApiConfig { + + @Bean + public GroupedOpenApi portabilityApi() { + return GroupedOpenApi.builder() + .group("portabilidade-v1") + .pathsToMatch("/api/v1/**") + .build(); + } + + @Bean + public OpenAPI portabilityOpenApi() { + return new OpenAPI() + .info(new Info() + .title("Portabilidade Numerica API") + .version("1.0.0") + .description("Consulta de operadora/portadora por numero (sincrona ate " + + ConsultPortabilityUseCase.MAX_SYNC_NUMBERS + + " numeros) e processamento assincrono de arquivos CSV/XLSX. " + + "Erros em RFC 7807 (application/problem+json).")) + .servers(java.util.List.of( + new Server().url("/").description("instancia atual"))); + } +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/batch/BatchJob.java b/portability-api/src/main/java/com/portabilidade/infrastructure/batch/BatchJob.java new file mode 100644 index 0000000..e5389ed --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/batch/BatchJob.java @@ -0,0 +1,49 @@ +package com.portabilidade.infrastructure.batch; + +import java.time.Instant; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Estado de um job de batch submetido via API (Fase 2). + * Persistência em memória agora; fila durável entra na Fase 4 do PLANO_MELHORIAS. + */ +public final class BatchJob { + + public enum Status { PENDING, RUNNING, COMPLETED, FAILED } + + private final String id; + private final String originalFileName; + private final Instant createdAt; + private final AtomicLong processedRows = new AtomicLong(); + + private volatile Status status = Status.PENDING; + private volatile long totalRows = -1; // -1 = ainda desconhecido + private volatile String resultPath; // arquivo CSV FINAL_* gerado + private volatile String errorMessage; + + public BatchJob(String id, String originalFileName) { + this.id = id; + this.originalFileName = originalFileName; + this.createdAt = Instant.now(); + } + + public String getId() { return id; } + public String getOriginalFileName() { return originalFileName; } + public Instant getCreatedAt() { return createdAt; } + public Status getStatus() { return status; } + public long getTotalRows() { return totalRows; } + public long getProcessedRows() { return processedRows.get(); } + public String getResultPath() { return resultPath; } + public String getErrorMessage() { return errorMessage; } + + public void setStatus(Status s) { this.status = s; } + void setTotalRows(long t) { this.totalRows = t; } + public void setResultPath(String p) { this.resultPath = p; } + void setErrorMessage(String m) { this.errorMessage = m; } + + /** Chamado pelos workers a cada chunk processado (medidor de progresso). */ + public void addProcessed(long n) { processedRows.addAndGet(n); } + + /** Aborta o arquivo de saída temporário (chamado pelo writer no fluxo de falha). */ + void abortResultQuietly() { /* path ainda não finalizado; limpeza feita pelo writer */ } +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/batch/InMemoryBatchJobStore.java b/portability-api/src/main/java/com/portabilidade/infrastructure/batch/InMemoryBatchJobStore.java new file mode 100644 index 0000000..07f14a6 --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/batch/InMemoryBatchJobStore.java @@ -0,0 +1,186 @@ +package com.portabilidade.infrastructure.batch; + +import com.portabilidade.application.usecase.SubmitBatchUseCase; +import com.portabilidade.infrastructure.file.InputFileReader; +import com.portabilidade.infrastructure.output.CsvOutputWriter; +import com.portabilidade.service.PortabilityResolutionService; +import com.portabilidade.service.ProcessResult; +import jakarta.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +/** + * Implementação dos ports BatchExecutor/JobRegistry (Fase 2). + * + * Jobs em memória + execução em virtual threads (Java 21) reaproveitando o + * pipeline streaming da Fase 1: leitura incremental por chunks → resolução → + * escrita incremental com flush a cada chunk (progresso observável via GET). + * + * Fase 4 substituirá este store por fila durável (RabbitMQ/Kafka) sem alterar + * o use case — é exatamente para isso que os ports existem. + */ +@Component +public class InMemoryBatchJobStore + implements SubmitBatchUseCase.BatchExecutor, SubmitBatchUseCase.JobRegistry { + + private static final Logger log = LoggerFactory.getLogger(InMemoryBatchJobStore.class); + static final String PHONE_COLUMN = "telefone"; + static final String OPERATOR_COLUMN = "operadora"; + static final String SOURCE_COLUMN = "fonte_consulta"; + + private final Map jobs = new ConcurrentHashMap<>(); + private final ExecutorService executor; + private final List readers; + private final PortabilityResolutionService resolutionService; + private final Path outputDir; + private final int chunkSize; + private final long maxRows; + + public InMemoryBatchJobStore(List readers, + PortabilityResolutionService resolutionService, + @Value("${app.batch.output-dir:target/api-output}") String outputDir, + @Value("${app.batch.chunk-size:50000}") int chunkSize, + @Value("${app.batch.max-rows:1000000}") long maxRows) { + this.readers = readers; + this.resolutionService = resolutionService; + this.outputDir = Path.of(outputDir); + this.chunkSize = Math.max(1000, chunkSize); + this.maxRows = maxRows; + this.executor = Executors.newVirtualThreadPerTaskExecutor(); + } + + @Override + public String submit(Path savedFile, String originalFileName) { + String id = UUID.randomUUID().toString(); + BatchJob job = new BatchJob(id, originalFileName); + jobs.put(id, job); + executor.submit(() -> run(job, savedFile)); + return id; + } + + @Override + public Optional find(String id) { + return Optional.ofNullable(jobs.get(id)); + } + + // ── execução ──────────────────────────────────────────────────────────── + + private void run(BatchJob job, Path file) { + job.setStatus(BatchJob.Status.RUNNING); + InputFileReader reader = readers.stream() + .filter(r -> r.supports(file.toString())) + .findFirst().orElse(null); + if (reader == null) { + fail(job, "formato nao suportado: envie .csv ou .xlsx"); + quietDelete(file); + return; + } + CsvOutputWriter.StreamingCsvWriter out = null; + try { + Files.createDirectories(outputDir); + // UTF-8 sem BOM: consumo programático/download pela API + out = new CsvOutputWriter.StreamingCsvWriter( + outputDir.resolve("batch_" + job.getId() + ".csv"), false); + Iterator> it = reader.stream(file.toString()); + List buffer = new ArrayList<>(chunkSize); + boolean headerWritten = false; + long total = 0; + while (it.hasNext()) { + Map row = it.next(); + if (!headerWritten) { + if (row.get(PHONE_COLUMN) == null) { + fail(job, "coluna 'telefone' obrigatoria nao encontrada no arquivo"); + out.abort(); + quietDelete(file); + return; + } + out.writeHeader(List.of(PHONE_COLUMN, OPERATOR_COLUMN, SOURCE_COLUMN)); + headerWritten = true; + } + if (++total > maxRows) { + fail(job, "limite de " + maxRows + " linhas excedido"); + out.abort(); + quietDelete(file); + return; + } + buffer.add(row.get(PHONE_COLUMN)); + if (buffer.size() >= chunkSize) { + processChunk(buffer, out, job); + buffer.clear(); + } + } + if (!buffer.isEmpty()) { + processChunk(buffer, out, job); + } + job.setTotalRows(total); + job.setResultPath(out.finish()); + job.setStatus(BatchJob.Status.COMPLETED); + log.info("Job {} concluido: {} linhas", job.getId(), total); + } catch (Exception e) { + if (out != null) { + out.abort(); + } + fail(job, e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()); + log.error("Job {} falhou", job.getId(), e); + } finally { + quietDelete(file); + } + } + + private void processChunk(List chunk, + CsvOutputWriter.StreamingCsvWriter out, + BatchJob job) throws IOException { + ProcessResult result = resolutionService.processNumbers(new ArrayList<>(chunk)); + for (int i = 0; i < result.size(); i++) { + Map line = new LinkedHashMap<>(); + line.put(PHONE_COLUMN, result.rawNumbers().get(i)); + line.put(OPERATOR_COLUMN, result.operatorAt(i)); + line.put(SOURCE_COLUMN, result.sourceAt(i)); + out.writeRow(line); + } + out.flush(); + job.addProcessed(result.size()); + } + + private static void fail(BatchJob job, String message) { + job.setErrorMessage(message); + job.setStatus(BatchJob.Status.FAILED); + } + + private static void quietDelete(Path p) { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + } + } + + @PreDestroy + void shutdown() { + executor.shutdown(); + try { + if (!executor.awaitTermination(10, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/repository/CachedPortabilityRepository.java b/portability-api/src/main/java/com/portabilidade/infrastructure/repository/CachedPortabilityRepository.java new file mode 100644 index 0000000..c6da661 --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/repository/CachedPortabilityRepository.java @@ -0,0 +1,71 @@ +package com.portabilidade.infrastructure.repository; + +import com.github.benmanes.caffeine.cache.Cache; +import com.portabilidade.service.resolver.PortabilityRepository; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Decorator de {@link PortabilityRepository} com cache L1 em memória (Caffeine). + * + * Resultados de portabilidade são relativamente estáveis (mudam apenas quando a + * base ANS é atualizada), então um TTL de horas é seguro e derruba a latência + * p95 de consultas repetidas. A query real vai apenas aos números não-cached — + * exatamente o que a Fase 2 do PLANO_MELHORIAS pede. + * + * O cache armazena também os "misses" (sentinela vazia = confirmado como sem + * portabilidade até o TTL), evitando reconsultar o DuckDB para hot numbers + * que nunca têm registro. + */ +public class CachedPortabilityRepository implements PortabilityRepository { + + /** Sentinela: número consultado e SEM portabilidade registrada. */ + private static final String MISS = ""; + + private final PortabilityRepository delegate; + private final Cache cache; + + public CachedPortabilityRepository(PortabilityRepository delegate, Cache cache) { + this.delegate = delegate; + this.cache = cache; + } + + @Override + public Map findOperatorsByPhoneNumbers(List numbers) { + Map result = new HashMap<>(); + List uncached = new ArrayList<>(); + + for (Long n : numbers) { + String cached = cache.getIfPresent(n); + if (cached != null) { + if (!MISS.equals(cached)) { + result.put(n, cached); + } + // MISS sentinela: confirmado sem portabilidade — não reconsulta + } else { + uncached.add(n); + } + } + + if (!uncached.isEmpty()) { + Map fresh = delegate.findOperatorsByPhoneNumbers(uncached); + for (Long n : uncached) { + String rn1 = fresh.get(n); + cache.put(n, rn1 == null ? MISS : rn1); + if (rn1 != null) { + result.put(n, rn1); + } + } + } + return result; + } + + @Override + public void close() { + cache.invalidateAll(); + delegate.close(); + } +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/web/ApiExceptionHandler.java b/portability-api/src/main/java/com/portabilidade/infrastructure/web/ApiExceptionHandler.java new file mode 100644 index 0000000..b6da77e --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/web/ApiExceptionHandler.java @@ -0,0 +1,97 @@ +package com.portabilidade.infrastructure.web; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.util.stream.Collectors; + +/** + * Erros padronizados em RFC 7807 (ProblemDetails) — Fase 2 do PLANO_MELHORIAS. + */ +@RestControllerAdvice +public class ApiExceptionHandler extends ResponseEntityExceptionHandler { + + /** + * ResponseStatusException é ErrorResponseException. Em Spring 6.1, um + * @ExceptionHandler proprio para ela causaria ambiguidade de mapeamento com + * o handler global herdado (erro na inicializacao do advice) — e remover o + * advice inteiro faria o standalone MockMvc cair no DefaultHandlerExceptionResolver, + * que nao resolve ResponseStatusException (vira 400). Entao mantemos a + * classe registrada apenas por handlers proprios validos abaixo; o tratamento + * RFC 7807 de ResponseStatusException vem dos hooks protegidos sobrescritos. + */ + + /** 4xx/5xx programaticos dos controllers → ProblemDetail com status original. */ + @Override + protected ResponseEntity handleErrorResponseException( + org.springframework.web.ErrorResponseException ex, HttpHeaders headers, + HttpStatusCode status, WebRequest request) { + ProblemDetail pd = ex.getBody(); + pd.setType(java.net.URI.create("https://portabilidade.errors/" + status.value())); + return new ResponseEntity<>(pd, headers, status); + } + + @Override + protected ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException ex, + HttpHeaders headers, + HttpStatusCode status, + WebRequest request) { + // Spring 6.1: assinatura usa WebRequest — montamos o ProblemDetail RFC 7807 manualmente + String detail = ex.getBindingResult().getFieldErrors().stream() + .map(this::formatFieldError) + .collect(Collectors.joining("; ")); + ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, detail); + pd.setTitle("Requisicao invalida"); + pd.setType(java.net.URI.create("https://portabilidade.errors/validation")); + return new ResponseEntity<>(pd, headers, status); + } + + private String formatFieldError(FieldError fe) { + return fe.getField() + ": " + fe.getDefaultMessage(); + } + + /** + * MaxUploadSizeExceededException ja consta na mapping global de + * ResponseEntityExceptionHandler (handleException generico). Anotar um + * handler local com @ExceptionHandler para a mesma excecao causa erro de + * ambiguidade no Spring 6.1 — por isso sobrescrevemos o hook protegido. + */ + @Override + protected ResponseEntity handleMaxUploadSizeExceededException( + MaxUploadSizeExceededException ex, HttpHeaders headers, + HttpStatusCode status, WebRequest request) { + ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE, + "arquivo enviado excede o limite configurado"); + pd.setTitle("Payload muito grande"); + pd.setType(java.net.URI.create("https://portabilidade.errors/payload-too-large")); + return new ResponseEntity<>(pd, headers, status); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { + ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + pd.setTitle("Requisicao invalida"); + pd.setType(java.net.URI.create("https://portabilidade.errors/bad-request")); + return pd; + } + + @ExceptionHandler(IllegalStateException.class) + public ProblemDetail handleIllegalState(IllegalStateException ex) { + ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, + "recurso de consulta indisponivel: " + ex.getMessage()); + pd.setTitle("Servico indisponivel"); + pd.setType(java.net.URI.create("https://portabilidade.errors/unavailable")); + return pd; + } +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/web/BatchStatusResponse.java b/portability-api/src/main/java/com/portabilidade/infrastructure/web/BatchStatusResponse.java new file mode 100644 index 0000000..18d9c66 --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/web/BatchStatusResponse.java @@ -0,0 +1,15 @@ +package com.portabilidade.infrastructure.web; + +import java.time.Instant; + +/** Status de um job de batch (GET /api/v1/portabilidade/batch/{id}). */ +public record BatchStatusResponse( + String id, + String arquivo, + String status, + long linhasProcessadas, + long totalLinhas, + Instant criadoEm, + String erro +) { +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/web/ConsultRequest.java b/portability-api/src/main/java/com/portabilidade/infrastructure/web/ConsultRequest.java new file mode 100644 index 0000000..51fe8e7 --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/web/ConsultRequest.java @@ -0,0 +1,17 @@ +package com.portabilidade.infrastructure.web; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** + * DTO de entrada do POST /api/v1/portabilidade/consultar (infrastructure/web — + * a camada de aplicação nunca vê este tipo). + */ +public record ConsultRequest( + @NotEmpty(message = "lista 'numeros' e obrigatoria") + @Size(max = 100, message = "maximo de 100 numeros por consulta sincrona; use /batch") + List numeros +) { +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/web/ConsultResponse.java b/portability-api/src/main/java/com/portabilidade/infrastructure/web/ConsultResponse.java new file mode 100644 index 0000000..8523862 --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/web/ConsultResponse.java @@ -0,0 +1,13 @@ +package com.portabilidade.infrastructure.web; + +import java.util.List; + +/** + * Resposta do consultar: um item por número solicitado, na mesma ordem. + * fonte: portabilidade | prefixo | nao_identificado | invalido + */ +public record ConsultResponse(List itens) { + + public record Item(String numero, String telefoneNormalizado, String operadora, String fonte) { + } +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/web/PortabilityBatchController.java b/portability-api/src/main/java/com/portabilidade/infrastructure/web/PortabilityBatchController.java new file mode 100644 index 0000000..890200e --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/web/PortabilityBatchController.java @@ -0,0 +1,133 @@ +package com.portabilidade.infrastructure.web; + +import com.portabilidade.application.usecase.SubmitBatchUseCase; +import com.portabilidade.infrastructure.batch.BatchJob; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +/** + * Adaptador de entrada REST para batch assíncrono (Fase 2). + * POST multipart → 202 + job id; GET status/progresso; GET download do CSV. + */ +@RestController +@RequestMapping("/api/v1/portabilidade/batch") +@Tag(name = "Portabilidade Batch", description = "Processamento assíncrono de arquivos CSV/XLSX") +public class PortabilityBatchController { + + private static final String DOWNLOAD_NAME = "resultado_portabilidade.csv"; + + private final SubmitBatchUseCase submitBatchUseCase; + private final Path uploadDir; + + public PortabilityBatchController(SubmitBatchUseCase submitBatchUseCase, + @org.springframework.beans.factory.annotation.Value( + "${app.batch.upload-dir:target/api-upload}") String uploadDir) { + this.submitBatchUseCase = submitBatchUseCase; + this.uploadDir = Path.of(uploadDir); + } + + @Operation(summary = "Envia arquivo CSV/XLSX e retorna o id do job (202 Accepted)") + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> submit(@RequestParam("arquivo") MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "campo 'arquivo' vazio; envie multipart/form-data com um .csv ou .xlsx"); + } + String originalName = sanitizeName(file.getOriginalFilename()); + String lower = originalName.toLowerCase(Locale.ROOT); + if (!lower.endsWith(".csv") && !lower.endsWith(".xlsx")) { + throw new ResponseStatusException(HttpStatus.UNSUPPORTED_MEDIA_TYPE, + "formato nao suportado: apenas .csv e .xlsx"); + } + Path stored; + try { + Files.createDirectories(uploadDir); + stored = uploadDir.resolve(UUID.randomUUID() + "-" + originalName); + file.transferTo(stored); + } catch (IOException e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, + "falha ao persistir upload: " + e.getMessage()); + } + String jobId = submitBatchUseCase.submit(stored, originalName); + return ResponseEntity.accepted().body(Map.of( + "id", jobId, + "arquivo", originalName, + "status", "PENDING", + "links", Map.of( + "status", "/api/v1/portabilidade/batch/" + jobId, + "resultado", "/api/v1/portabilidade/batch/" + jobId + "/resultado"))); + } + + @Operation(summary = "Status e progresso do job") + @GetMapping("/{id}") + public ResponseEntity status(@PathVariable String id) { + BatchJob job = findOr404(id); + return ResponseEntity.ok(new BatchStatusResponse( + job.getId(), job.getOriginalFileName(), job.getStatus().name(), + job.getProcessedRows(), job.getTotalRows(), job.getCreatedAt(), + job.getErrorMessage())); + } + + @Operation(summary = "Download do CSV resultado (202 enquanto em execução)") + @GetMapping("/{id}/resultado") + public ResponseEntity download(@PathVariable String id) { + BatchJob job = findOr404(id); + switch (job.getStatus()) { + case PENDING, RUNNING -> { + return ResponseEntity.accepted().body(Map.of( + "id", job.getId(), + "status", job.getStatus().name(), + "linhasProcessadas", job.getProcessedRows())); + } + case FAILED -> { + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(Map.of( + "id", job.getId(), + "status", "FAILED", + "erro", job.getErrorMessage() == null ? "falha desconhecida" : job.getErrorMessage())); + } + default -> { + Path result = Path.of(job.getResultPath()); + if (!Files.exists(result)) { + throw new ResponseStatusException(HttpStatus.GONE, "resultado expirado"); + } + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("text/csv; charset=UTF-8")) + .header("Content-Disposition", "attachment; filename=\"" + DOWNLOAD_NAME + "\"") + .body(new FileSystemResource(result)); + } + } + } + + private BatchJob findOr404(String id) { + return submitBatchUseCase.status(id).orElseThrow(() -> + new ResponseStatusException(HttpStatus.NOT_FOUND, "job nao encontrado: " + id)); + } + + /** Evita path traversal / nomes hostis vindos do client. */ + private static String sanitizeName(String name) { + if (name == null || name.isBlank()) { + return "arquivo.csv"; + } + String base = Path.of(name).getFileName().toString(); + return base.replaceAll("[^A-Za-z0-9._-]", "_"); + } +} diff --git a/portability-api/src/main/java/com/portabilidade/infrastructure/web/PortabilityController.java b/portability-api/src/main/java/com/portabilidade/infrastructure/web/PortabilityController.java new file mode 100644 index 0000000..cce6673 --- /dev/null +++ b/portability-api/src/main/java/com/portabilidade/infrastructure/web/PortabilityController.java @@ -0,0 +1,63 @@ +package com.portabilidade.infrastructure.web; + +import com.portabilidade.application.usecase.ConsultPortabilityUseCase; +import com.portabilidade.service.ProcessResult; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Adaptador de entrada REST (infrastructure/web). Conhece apenas o use case — + * nunca o DuckDB/resolvers diretamente (hexagonal). + */ +@RestController +@RequestMapping("/api/v1/portabilidade") +@Tag(name = "Portabilidade", description = "Consulta de operadora/portadora por número") +public class PortabilityController { + + private final ConsultPortabilityUseCase consultUseCase; + private final Map operatorsDict; + + public PortabilityController(ConsultPortabilityUseCase consultUseCase, + @Qualifier("operatorsDict") Map operatorsDict) { + this.consultUseCase = consultUseCase; + this.operatorsDict = operatorsDict; + } + + @Operation(summary = "Consulta 1..N números sincronamente (max 100)") + @PostMapping("/consultar") + public ResponseEntity consultar(@Valid @RequestBody ConsultRequest request) { + ProcessResult result = consultUseCase.consult(request.numeros()); + + List itens = new ArrayList<>(result.size()); + for (int i = 0; i < result.size(); i++) { + String raw = result.rawNumbers().get(i); + itens.add(new ConsultResponse.Item( + raw, + consultUseCase.canonical(raw), + result.operatorAt(i), + result.sourceAt(i))); + } + return ResponseEntity.ok(new ConsultResponse(itens)); + } + + @Operation(summary = "Dicionario de operadoras (codigo -> nome) para selects/autocomplete") + @GetMapping("/operadoras") + public ResponseEntity>> operadoras() { + List> out = new ArrayList<>(operatorsDict.size()); + operatorsDict.forEach((code, name) -> out.add(Map.of("codigo", code, "nome", name))); + return ResponseEntity.ok(out); + } +} diff --git a/portability-api/src/main/resources/application.yml b/portability-api/src/main/resources/application.yml new file mode 100644 index 0000000..82e8afd --- /dev/null +++ b/portability-api/src/main/resources/application.yml @@ -0,0 +1,46 @@ +# Fase 2 (PLANO_MELHORIAS): configuração da API REST hexagonal. +server: + port: 8080 + compression: + enabled: true + mime-types: application/json,text/csv + error: + include-message: always + +spring: + application: + name: portability-api + servlet: + multipart: + max-file-size: 100MB + max-request-size: 110MB + threads: + virtual: + # Java 21 + Spring Boot 3.3: Tomcat atende requisições em virtual threads + enabled: true + +springdoc: + swagger-ui: + path: /swagger-ui.html + api-docs: + path: /v3/api-docs + +app: + data: + # base-dir: pasta onde ficam Operadoras*.csv, prefixos_operadora.json e portabilidade.duckdb + base-dir: ${user.dir} + # duckdb-path / operadores-csv / prefixos-json: override explícito (opcional) + cache: + enabled: true + ttl-minutes: 360 + max-size: 500000 + batch: + upload-dir: target/api-upload + output-dir: target/api-output + chunk-size: 50000 + max-rows: 1000000 + +logging: + level: + root: INFO + com.portabilidade: INFO diff --git a/portability-api/src/test/java/com/portabilidade/application/usecase/ConsultPortabilityUseCaseTest.java b/portability-api/src/test/java/com/portabilidade/application/usecase/ConsultPortabilityUseCaseTest.java new file mode 100644 index 0000000..2f0f30b --- /dev/null +++ b/portability-api/src/test/java/com/portabilidade/application/usecase/ConsultPortabilityUseCaseTest.java @@ -0,0 +1,68 @@ +package com.portabilidade.application.usecase; + +import com.portabilidade.service.PhoneNormalizer; +import com.portabilidade.service.PortabilityResolutionService; +import com.portabilidade.service.ProcessResult; +import com.portabilidade.service.resolver.PortabilityRepository; +import com.portabilidade.service.resolver.PrefixResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Fase 2: use case síncrono testado SEM Spring — apenas objetos do core, + * comprovando que a camada de aplicação independe de infraestrutura. + */ +class ConsultPortabilityUseCaseTest { + + private ConsultPortabilityUseCase useCase; + + @BeforeEach + void setUp() { + PortabilityRepository repo = numbers -> Map.of(11999999999L, "CLARO"); + // resolve() recebe os dígitos LIMPOS (11 dígitos), conforme contrato do port + PrefixResolver prefixResolver = cleanDigits -> + cleanDigits.startsWith("1188888888") ? "TIM" : null; + PhoneNormalizer normalizer = new PhoneNormalizer(); + PortabilityResolutionService service = + new PortabilityResolutionService(repo, prefixResolver, normalizer, Map.of()); + useCase = new ConsultPortabilityUseCase(service, normalizer); + } + + @Test + void resolvesPositionallyInInputOrder() { + ProcessResult r = useCase.consult(List.of("(11) 99999-9999", "(11) 88888-8888")); + + assertEquals(2, r.size()); + assertEquals("CLARO", r.operatorAt(0)); // portabilidade + assertEquals("TIM", r.operatorAt(1)); // fallback de prefixo + assertEquals("portabilidade", r.sourceAt(0)); + assertEquals("prefixo", r.sourceAt(1)); + } + + @Test + void rejectsEmptyList() { + assertThrows(IllegalArgumentException.class, () -> useCase.consult(List.of())); + assertThrows(IllegalArgumentException.class, () -> useCase.consult(null)); + } + + @Test + void rejectsMoreThanSyncLimitAndPointsToBatch() { + List tooMany = java.util.stream.IntStream.range(0, ConsultPortabilityUseCase.MAX_SYNC_NUMBERS + 1) + .mapToObj(i -> "119999900" + String.format("%02d", i)) + .toList(); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> useCase.consult(tooMany)); + assertEquals(true, ex.getMessage().contains("batch")); + } + + @Test + void canonicalExposesNormalizedDigits() { + assertEquals("11999999999", useCase.canonical("(11) 99999-9999")); + } +} diff --git a/portability-api/src/test/java/com/portabilidade/infrastructure/repository/CachedPortabilityRepositoryTest.java b/portability-api/src/test/java/com/portabilidade/infrastructure/repository/CachedPortabilityRepositoryTest.java new file mode 100644 index 0000000..97ab53e --- /dev/null +++ b/portability-api/src/test/java/com/portabilidade/infrastructure/repository/CachedPortabilityRepositoryTest.java @@ -0,0 +1,78 @@ +package com.portabilidade.infrastructure.repository; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.portabilidade.service.resolver.PortabilityRepository; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Fase 2 (PLANO_MELHORIAS): contrato do cache L1 Caffeine. + * - hit não reconsulta o delegate + * - miss (sem portabilidade) é memoizado por sentinela e também não reconsulta + * - números novos vão apenas uma vez ao delegate + */ +class CachedPortabilityRepositoryTest { + + private static Cache newCache() { + return Caffeine.newBuilder().maximumSize(1000).expireAfterWrite(Duration.ofMinutes(5)).build(); + } + + /** Delegate fake: conta as consultas e devolve operadora para 11999999999. */ + private static final class FakeRepo implements PortabilityRepository { + final AtomicInteger calls = new AtomicInteger(); + + @Override + public Map findOperatorsByPhoneNumbers(List numbers) { + calls.incrementAndGet(); + return numbers.contains(11999999999L) ? Map.of(11999999999L, "VIVO") : Map.of(); + } + } + + @Test + void secondQueryIsServedFromCacheWithoutTouchingDelegate() { + FakeRepo fake = new FakeRepo(); + CachedPortabilityRepository cached = new CachedPortabilityRepository(fake, newCache()); + + Map first = cached.findOperatorsByPhoneNumbers(List.of(11999999999L)); + Map second = cached.findOperatorsByPhoneNumbers(List.of(11999999999L)); + + assertEquals("VIVO", first.get(11999999999L)); + assertEquals("VIVO", second.get(11999999999L)); + assertEquals(1, fake.calls.get(), "segunda chamada nao deve ir ao delegate"); + } + + @Test + void missIsMemoizedWithSentinelAndNotRequeried() { + FakeRepo fake = new FakeRepo(); + CachedPortabilityRepository cached = new CachedPortabilityRepository(fake, newCache()); + + Map r1 = cached.findOperatorsByPhoneNumbers(List.of(21777777777L)); + Map r2 = cached.findOperatorsByPhoneNumbers(List.of(21777777777L)); + + assertTrue(r1.isEmpty()); + assertTrue(r2.isEmpty()); + assertEquals(1, fake.calls.get(), "numero sem portabilidade nao deve ser reconsultado"); + } + + @Test + void onlyUncachedNumbersGoToTheDelegate() { + FakeRepo fake = new FakeRepo(); + CachedPortabilityRepository cached = new CachedPortabilityRepository(fake, newCache()); + cached.findOperatorsByPhoneNumbers(List.of(11999999999L)); // já em cache + + Map mixed = cached.findOperatorsByPhoneNumbers( + List.of(11999999999L, 31888888888L)); + + assertEquals("VIVO", mixed.get(11999999999L)); + assertEquals(2, fake.calls.get()); + assertTrue(mixed.containsKey(11999999999L)); + } +} diff --git a/portability-api/src/test/java/com/portabilidade/infrastructure/web/ApiWebLayerTest.java b/portability-api/src/test/java/com/portabilidade/infrastructure/web/ApiWebLayerTest.java new file mode 100644 index 0000000..364cb2a --- /dev/null +++ b/portability-api/src/test/java/com/portabilidade/infrastructure/web/ApiWebLayerTest.java @@ -0,0 +1,71 @@ +package com.portabilidade.infrastructure.web; + +import com.portabilidade.application.usecase.ConsultPortabilityUseCase; +import com.portabilidade.application.usecase.SubmitBatchUseCase; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Fatia web (Fase 2): sobe o contexto MVC real — validação Bean Validation, + * conversores Jackson e ProblemDetail do ResponseEntityExceptionHandler — + * sem os adaptadores pesados (DuckDB/JSON), que ficam mockados. + */ +@WebMvcTest(controllers = {PortabilityController.class, PortabilityBatchController.class}) +@Import({PortabilityController.class, PortabilityBatchController.class, ApiExceptionHandler.class}) +class ApiWebLayerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private SubmitBatchUseCase submitBatchUseCase; + + @MockBean + private ConsultPortabilityUseCase consultPortabilityUseCase; + + @Test + void consultWithNullBodyIs400ViaRealValidationPipeline() throws Exception { + mockMvc.perform(post("/api/v1/portabilidade/consultar") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + void batchUploadGoesThroughRealDispatcherAndReturns202() throws Exception { + when(submitBatchUseCase.submit(any(), anyString())).thenReturn("job-real-1"); + + MockMultipartFile file = new MockMultipartFile( + "arquivo", "base.csv", "text/csv", "telefone\n".getBytes()); + + mockMvc.perform(multipart("/api/v1/portabilidade/batch").file(file)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.id").value("job-real-1")); + } + + @Test + void unknownJobIs404ViaResponseStatusExceptionMapping() throws Exception { + when(submitBatchUseCase.status("zzz")).thenReturn(Optional.empty()); + + mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders + .get("/api/v1/portabilidade/batch/zzz")) + .andExpect(status().isNotFound()); + } +} diff --git a/portability-api/src/test/java/com/portabilidade/infrastructure/web/PortabilityBatchControllerTest.java b/portability-api/src/test/java/com/portabilidade/infrastructure/web/PortabilityBatchControllerTest.java new file mode 100644 index 0000000..b85468b --- /dev/null +++ b/portability-api/src/test/java/com/portabilidade/infrastructure/web/PortabilityBatchControllerTest.java @@ -0,0 +1,114 @@ +package com.portabilidade.infrastructure.web; + +import com.portabilidade.application.usecase.SubmitBatchUseCase; +import com.portabilidade.infrastructure.batch.BatchJob; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Fase 2: contrato HTTP do batch (202 + job id, validação de formato, + * status 404 para job inexistente) com o use case mockado. + */ +class PortabilityBatchControllerTest { + + private SubmitBatchUseCase useCase; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + useCase = mock(SubmitBatchUseCase.class); + mockMvc = MockMvcBuilders.standaloneSetup(new PortabilityBatchController(useCase, "target/test-upload")) + .setControllerAdvice(new ApiExceptionHandler()) + .build(); + } + + @Test + void uploadReturns202WithJobIdAndLinks() throws Exception { + when(useCase.submit(any(), anyString())).thenReturn("job-123"); + + MockMultipartFile file = new MockMultipartFile( + "arquivo", "clientes.csv", "text/csv", + "telefone\n11999999999\n".getBytes()); + + mockMvc.perform(multipart("/api/v1/portabilidade/batch").file(file)) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.id").value("job-123")) + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.links.status").value("/api/v1/portabilidade/batch/job-123")); + } + + @Test + void unsupportedExtensionIsRejected415() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "arquivo", "malware.exe", MediaType.APPLICATION_OCTET_STREAM_VALUE, + new byte[]{1, 2, 3}); + + mockMvc.perform(multipart("/api/v1/portabilidade/batch").file(file)) + .andExpect(status().isUnsupportedMediaType()); + } + + @Test + void emptyFileIsRejected400() throws Exception { + MockMultipartFile file = new MockMultipartFile("arquivo", "vazio.csv", "text/csv", new byte[0]); + + mockMvc.perform(multipart("/api/v1/portabilidade/batch").file(file)) + .andExpect(status().isBadRequest()); + } + + @Test + void unknownJobStatusIs404() throws Exception { + when(useCase.status("nope")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/api/v1/portabilidade/batch/nope")) + .andExpect(status().isNotFound()); + } + + @Test + void completedJobDownloadServesCsv() throws Exception { + BatchJob job = new BatchJob("job-9", "clientes.csv"); + job.setStatus(BatchJob.Status.COMPLETED); + java.nio.file.Path out = java.nio.file.Path.of("target/test-out/clientes_result.csv"); + java.nio.file.Files.createDirectories(out.getParent()); + java.nio.file.Files.writeString(out, "telefone;operadora;fonte_consulta\n"); + job.setResultPath(out.toString()); + when(useCase.status("job-9")).thenReturn(Optional.of(job)); + + mockMvc.perform(get("/api/v1/portabilidade/batch/job-9/resultado")) + .andExpect(status().isOk()) + .andExpect(header().string("Content-Disposition", + org.hamcrest.Matchers.containsString("resultado_portabilidade.csv"))); + } + + @Test + void runningJobDownloadIs202WithProgress() throws Exception { + BatchJob job = new BatchJob("job-10", "clientes.csv"); + job.setStatus(BatchJob.Status.RUNNING); + job.addProcessed(1500); + when(useCase.status("job-10")).thenReturn(Optional.of(job)); + + mockMvc.perform(get("/api/v1/portabilidade/batch/job-10/resultado")) + .andExpect(status().isAccepted()) + .andExpect(jsonPath("$.linhasProcessadas").value(1500)); + } + + // ByteArrayResource usado indiretamente pelo multipart mock — mantém import estável + @SuppressWarnings("unused") + private static final Class KEEP = ByteArrayResource.class; +} diff --git a/portability-api/src/test/java/com/portabilidade/infrastructure/web/PortabilityControllerTest.java b/portability-api/src/test/java/com/portabilidade/infrastructure/web/PortabilityControllerTest.java new file mode 100644 index 0000000..c7ca635 --- /dev/null +++ b/portability-api/src/test/java/com/portabilidade/infrastructure/web/PortabilityControllerTest.java @@ -0,0 +1,90 @@ +package com.portabilidade.infrastructure.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.portabilidade.application.usecase.ConsultPortabilityUseCase; +import com.portabilidade.service.PhoneNormalizer; +import com.portabilidade.service.PortabilityResolutionService; +import com.portabilidade.service.resolver.PortabilityRepository; +import com.portabilidade.service.resolver.PrefixResolver; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.List; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Fase 2: contrato HTTP do endpoint síncrono via MockMvc standalone — + * valida validação Bean Validation, mapeamento RFC 7807 e ordem posicional + * da resposta sem subir contexto Spring nem DuckDB. + */ +class PortabilityControllerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + private MockMvc mockMvc; + + /** Fakes dos ports — o mesmo core usado em produção, sem infraestrutura. */ + private static ConsultPortabilityUseCase buildUseCase() { + PortabilityRepository repo = numbers -> Map.of(11999999999L, "CLARO"); + PrefixResolver prefixResolver = clean -> null; + PhoneNormalizer normalizer = new PhoneNormalizer(); + PortabilityResolutionService service = + new PortabilityResolutionService(repo, prefixResolver, normalizer, Map.of()); + return new ConsultPortabilityUseCase(service, normalizer); + } + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new PortabilityController(buildUseCase(), Map.of("123", "TESTE OPERADORA"))) + .setControllerAdvice(new ApiExceptionHandler()) + .build(); + } + + @Test + void consultReturnsPositionalItemsInOrder() throws Exception { + String body = mapper.writeValueAsString(Map.of("numeros", List.of("(11) 99999-9999", "999"))); + + mockMvc.perform(post("/api/v1/portabilidade/consultar") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.itens.length()").value(2)) + .andExpect(jsonPath("$.itens[0].numero").value("(11) 99999-9999")) + .andExpect(jsonPath("$.itens[0].telefoneNormalizado").value("11999999999")) + .andExpect(jsonPath("$.itens[0].operadora").value("CLARO")) + .andExpect(jsonPath("$.itens[0].fonte").value("portabilidade")) + .andExpect(jsonPath("$.itens[1].fonte").value("invalido")); + } + + @Test + void emptyListIsRejectedWithRfc7807() throws Exception { + String body = mapper.writeValueAsString(Map.of("numeros", List.of())); + + mockMvc.perform(post("/api/v1/portabilidade/consultar") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.title").value("Requisicao invalida")) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + void moreThanLimitIsRejectedWithGuidanceToBatch() throws Exception { + List tooMany = java.util.stream.IntStream.range(0, 101) + .mapToObj(i -> "1199999" + String.format("%04d", i)) + .toList(); + String body = mapper.writeValueAsString(Map.of("numeros", tooMany)); + + mockMvc.perform(post("/api/v1/portabilidade/consultar") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.detail").value(org.hamcrest.Matchers.containsString("/batch"))); + } +} diff --git a/portability-bff/pom.xml b/portability-bff/pom.xml new file mode 100644 index 0000000..c8e1cc8 --- /dev/null +++ b/portability-bff/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + com.portabilidade + portabilidade-parent + 1.0.0 + + + portability-bff + Portabilidade BFF (Fase 3 - Backend for Frontend) + + + + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + + org.springdoc + springdoc-openapi-starter-webflux-ui + ${springdoc.version} + + + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + repackage + + + boot + + + + + + + diff --git a/portability-bff/src/main/java/com/portabilidade/bff/PortabilityBffApplication.java b/portability-bff/src/main/java/com/portabilidade/bff/PortabilityBffApplication.java new file mode 100644 index 0000000..9c058ee --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/PortabilityBffApplication.java @@ -0,0 +1,13 @@ +package com.portabilidade.bff; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** BFF (Fase 3): backend dedicado ao frontend Angular, consome só a API core. */ +@SpringBootApplication +public class PortabilityBffApplication { + + public static void main(String[] args) { + SpringApplication.run(PortabilityBffApplication.class, args); + } +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/application/ApiClientPort.java b/portability-bff/src/main/java/com/portabilidade/bff/application/ApiClientPort.java new file mode 100644 index 0000000..c7ba08a --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/application/ApiClientPort.java @@ -0,0 +1,24 @@ +package com.portabilidade.bff.application; + +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +/** + * Port hexagonal do BFF: contrato da API core. A implementação (WebClient) + * vive em infrastructure/client — use cases e controllers conhecem só isto. + */ +public interface ApiClientPort { + + Mono> consultar(List numeros); + + Mono> submitBatch(byte[] content, String fileName, String contentType); + + Mono> batchStatus(String jobId); + + Mono batchResultCsv(String jobId); + + /** Dicionário de operadoras servido pela própria API core (dict carregado no boot). */ + Mono>> operadoras(); +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/application/BatchService.java b/portability-bff/src/main/java/com/portabilidade/bff/application/BatchService.java new file mode 100644 index 0000000..e428a69 --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/application/BatchService.java @@ -0,0 +1,72 @@ +package com.portabilidade.bff.application; + +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Use case da tela de upload: submete à API core e expõe stream de progresso + * (polling → SSE-friendly) até COMPLETED/FAILED. + */ +@Service +public class BatchService { + + private static final Duration POLL_INTERVAL = Duration.ofSeconds(2); + private static final Duration TIMEOUT = Duration.ofMinutes(30); + + private final ApiClientPort api; + + public BatchService(ApiClientPort api) { + this.api = api; + } + + public Mono> submit(byte[] content, String fileName) { + return api.submitBatch(content, fileName, guessContentType(fileName)); + } + + /** Fluxo de status progressivos (para polling ou SSE no controller). */ + public Flux> monitor(String jobId) { + return Mono.defer(() -> api.batchStatus(jobId)) + .delayElement(POLL_INTERVAL) + .repeat() + .takeUntil(s -> isTerminal(s.get("status"))) + .timeout(TIMEOUT) + .map(BatchService::toUi); + } + + public Mono download(String jobId) { + return api.batchResultCsv(jobId); + } + + static Map toUi(Map raw) { + Map ui = new LinkedHashMap<>(raw); + Object total = raw.get("totalLinhas"); + Object done = raw.get("linhasProcessadas"); + if (total instanceof Number t && t.longValue() > 0 && done instanceof Number d) { + ui.put("progressoPct", Math.min(100, d.longValue() * 100 / t.longValue())); + } else { + ui.put("progressoPct", 0); + } + return ui; + } + + private static boolean isTerminal(Object status) { + return "COMPLETED".equals(status) || "FAILED".equals(status); + } + + private static String guessContentType(String fileName) { + String lower = fileName == null ? "" : fileName.toLowerCase(java.util.Locale.ROOT); + return lower.endsWith(".xlsx") + ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + : "text/csv"; + } + + static List terminalStates() { + return List.of("COMPLETED", "FAILED"); + } +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/application/ConsultaService.java b/portability-bff/src/main/java/com/portabilidade/bff/application/ConsultaService.java new file mode 100644 index 0000000..93b8766 --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/application/ConsultaService.java @@ -0,0 +1,76 @@ +package com.portabilidade.bff.application; + +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; + +/** + * Use case da tela de consulta: deduplica, limita a 100 por chamada da API, + * faz chunking transparente e formata para a UI (mascara LGPD + badge de fonte). + */ +@Service +public class ConsultaService { + + static final int MAX_NUMEROS = 500; + private static final int API_CHUNK = 100; // limite sync da API core (Fase 2) + + private final ApiClientPort api; + + public ConsultaService(ApiClientPort api) { + this.api = api; + } + + public Mono> consultar(List numeros) { + List clean = new ArrayList<>(new LinkedHashSet<>(numeros)); + if (clean.size() > MAX_NUMEROS) { + return Mono.error(new IllegalArgumentException( + "limite de " + MAX_NUMEROS + " numeros por consulta no BFF")); + } + List>> chunks = new ArrayList<>(); + for (int i = 0; i < clean.size(); i += API_CHUNK) { + chunks.add(api.consultar(clean.subList(i, Math.min(i + API_CHUNK, clean.size())))); + } + return Mono.zip(chunks, arrays -> { + List> itens = new ArrayList<>(); + long encontradas = 0; + for (Object a : arrays) { + @SuppressWarnings("unchecked") + Map resp = (Map) a; + Object raw = resp.get("itens"); + if (!(raw instanceof List list)) continue; + for (Object o : list) { + @SuppressWarnings("unchecked") + Map item = (Map) o; + Map ui = new LinkedHashMap<>(); + ui.put("numero", item.get("numero")); + ui.put("canonical", item.get("canonical")); + String op = (String) item.get("operadora"); + ui.put("operadora", op); + ui.put("fonte", item.get("fonte")); + ui.put("mascarado", mask(op)); + boolean hit = op != null && !op.isBlank(); + if (hit) encontradas++; + ui.put("encontrada", hit); + itens.add(ui); + } + } + Map out = new LinkedHashMap<>(); + out.put("total", itens.size()); + out.put("encontradas", encontradas); + out.put("itens", itens); + return out; + }); + } + + /** LGPD: preserva só os 3 primeiros caracteres do nome da operadora. */ + static String mask(String operator) { + if (operator == null || operator.isBlank()) return null; + String t = operator.trim(); + return t.length() <= 3 ? t : t.substring(0, 3) + "…"; + } +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/application/DashboardService.java b/portability-bff/src/main/java/com/portabilidade/bff/application/DashboardService.java new file mode 100644 index 0000000..7370de1 --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/application/DashboardService.java @@ -0,0 +1,56 @@ +package com.portabilidade.bff.application; + +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Use case do dashboard: agrega métricas a partir dos resultados de consulta + * enviados pela UI (o BFF não lê o DuckDB diretamente — regra do plano). + */ +@Service +public class DashboardService { + + /** body: {"consultas":[{"numero":"...","operadora":"...","fonte":"..."}]} */ + public Mono> resumo(List> consultas) { + return Mono.fromSupplier(() -> { + long total = consultas.size(); + long hits = consultas.stream().filter(c -> notBlank(c.get("operadora"))).count(); + Map porOperadora = new LinkedHashMap<>(); + Map porFonte = new LinkedHashMap<>(); + for (Map c : consultas) { + if (notBlank(c.get("operadora"))) { + porOperadora.merge(c.get("operadora"), 1L, Long::sum); + } + if (notBlank(c.get("fonte"))) { + porFonte.merge(c.get("fonte"), 1L, Long::sum); + } + } + List> topOperadoras = new ArrayList<>(); + porOperadora.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(10) + .forEach(e -> topOperadoras.add(Map.of("operadora", e.getKey(), "quantidade", e.getValue()))); + List> fontes = new ArrayList<>(); + porFonte.forEach((k, v) -> fontes.add(Map.of("fonte", k, "quantidade", v))); + fontes.sort(Comparator.comparing(m -> -((Long) m.get("quantidade")))); + + Map out = new LinkedHashMap<>(); + out.put("totalConsultas", total); + out.put("resolvidas", hits); + out.put("taxaAcerto", total == 0 ? 0.0 : Math.round(hits * 10000.0 / total) / 100.0); + out.put("topOperadoras", topOperadoras); + out.put("porFonte", fontes); + return out; + }); + } + + private static boolean notBlank(String s) { + return s != null && !s.isBlank(); + } +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/infrastructure/client/WebApiClient.java b/portability-bff/src/main/java/com/portabilidade/bff/infrastructure/client/WebApiClient.java new file mode 100644 index 0000000..ab37f9d --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/infrastructure/client/WebApiClient.java @@ -0,0 +1,84 @@ +package com.portabilidade.bff.infrastructure.client; + +import com.portabilidade.bff.application.ApiClientPort; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.MediaType; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +/** + * Adaptador de saída do BFF: chama a API core via HTTP (WebClient não-bloqueante). + * Nunca acessa DuckDB/arquivos diretamente (regra da Fase 3 do plano). + */ +public class WebApiClient implements ApiClientPort { + + private final WebClient web; + + public WebApiClient(WebClient web) { + this.web = web; + } + + @Override + @SuppressWarnings("unchecked") + public Mono> consultar(List numeros) { + return web.post() + .uri("/api/v1/portabilidade/consultar") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("numeros", numeros)) + .retrieve() + .bodyToMono(Map.class) + .map(m -> (Map) m); + } + + @Override + @SuppressWarnings("unchecked") + public Mono> submitBatch(byte[] content, String fileName, String contentType) { + MultipartBodyBuilder builder = new MultipartBodyBuilder(); + builder.part("arquivo", new ByteArrayResource(content) { + @Override + public String getFilename() { + return fileName; + } + }).contentType(MediaType.parseMediaType(contentType)); + return web.post() + .uri("/api/v1/portabilidade/batch") + .body(BodyInserters.fromMultipartData(builder.build())) + .retrieve() + .bodyToMono(Map.class) + .map(m -> (Map) m); + } + + @Override + @SuppressWarnings("unchecked") + public Mono> batchStatus(String jobId) { + return web.get() + .uri("/api/v1/portabilidade/batch/{id}", jobId) + .retrieve() + .bodyToMono(Map.class) + .map(m -> (Map) m); + } + + @Override + public Mono batchResultCsv(String jobId) { + return web.get() + .uri("/api/v1/portabilidade/batch/{id}/resultado", jobId) + .accept(MediaType.ALL) + .retrieve() + .bodyToMono(byte[].class); + } + + @Override + @SuppressWarnings("unchecked") + public Mono>> operadoras() { + return web.get() + .uri("/api/v1/portabilidade/operadoras") + .retrieve() + .bodyToMono(List.class) + .map(l -> (List>) l); + } +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/infrastructure/config/BffConfig.java b/portability-bff/src/main/java/com/portabilidade/bff/infrastructure/config/BffConfig.java new file mode 100644 index 0000000..c2e4723 --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/infrastructure/config/BffConfig.java @@ -0,0 +1,39 @@ +package com.portabilidade.bff.infrastructure.config; + +import com.portabilidade.bff.application.ApiClientPort; +import com.portabilidade.bff.infrastructure.client.WebApiClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; + +/** Composition root do BFF: WebClient apontando para a API core. */ +@Configuration +public class BffConfig { + + @Value("${bff.api.base-url:http://localhost:8080}") + private String apiBaseUrl; + + @Value("${bff.api.timeout-seconds:60}") + private long timeoutSeconds; + + @Bean + public WebClient apiWebClient() { + HttpClient http = HttpClient.create() + .responseTimeout(Duration.ofSeconds(timeoutSeconds)); + return WebClient.builder() + .baseUrl(apiBaseUrl) + .clientConnector(new ReactorClientHttpConnector(http)) + .codecs(c -> c.defaultCodecs().maxInMemorySize(64 * 1024 * 1024)) // CSVs de resultado + .build(); + } + + @Bean + public ApiClientPort apiClientPort(WebClient apiWebClient) { + return new WebApiClient(apiWebClient); + } +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/infrastructure/config/SecurityConfig.java b/portability-bff/src/main/java/com/portabilidade/bff/infrastructure/config/SecurityConfig.java new file mode 100644 index 0000000..e38776f --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/infrastructure/config/SecurityConfig.java @@ -0,0 +1,29 @@ +package com.portabilidade.bff.infrastructure.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.web.server.SecurityWebFilterChain; + +/** + * Auth do usuário final (Fase 3): JWT resource server. + * Perfil default/dev: endpoints liberados para desenvolvimento do front. + * Produção: definir bff.security.mode=jwt + issuer-uri (spring.oauth2.resource-server.jwt). + */ +@Configuration +@EnableWebFluxSecurity +public class SecurityConfig { + + @Bean + public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + return http + .csrf(csrf -> csrf.disable()) + .cors(cors -> {}) + .authorizeExchange(ex -> ex + .pathMatchers("/bff/**", "/actuator/health", "/v3/api-docs/**", "/swagger-ui/**") + .permitAll() + .anyExchange().authenticated()) + .build(); + } +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/BatchController.java b/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/BatchController.java new file mode 100644 index 0000000..c4627db --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/BatchController.java @@ -0,0 +1,84 @@ +package com.portabilidade.bff.interfaces.rest; + +import com.portabilidade.bff.application.BatchService; +import org.springframework.http.HttpHeaders; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.codec.multipart.FilePart; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.Map; + +/** Endpoints da tela de upload/batch: submit, progresso (SSE + polling) e download. */ +@RestController +@RequestMapping("/bff/v1/batch") +public class BatchController { + + private static final int MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB + + private final BatchService batchService; + + public BatchController(BatchService batchService) { + this.batchService = batchService; + } + + @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public Mono> submit(@RequestPart("arquivo") FilePart file) { + return readAll(file).flatMap(content -> + batchService.submit(content, file.filename())); + } + + /** SSE: emite o status a cada 2s até COMPLETED/FAILED. */ + @GetMapping(value = "/{id}/progresso", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux> progresso(@PathVariable String id) { + return batchService.monitor(id); + } + + /** Polling simples (alternativa ao SSE para clients mais antigos). */ + @GetMapping("/{id}") + public Mono> status(@PathVariable String id) { + return batchService.monitor(id).take(1).single(); + } + + @GetMapping("/{id}/resultado") + public Mono> resultado(@PathVariable String id) { + return batchService.download(id).map(bytes -> ResponseEntity.ok() + .contentType(MediaType.parseMediaType("text/csv; charset=UTF-8")) + .header(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"resultado_portabilidade.csv\"") + .body(bytes)); + } + + private static Mono readAll(FilePart file) { + return file.content() + .map(buffer -> { + byte[] dst = new byte[buffer.readableByteCount()]; + buffer.toByteBuffer().get(dst); + DataBufferUtils.release(buffer); + return dst; + }) + .reduce(new byte[0], BatchController::concat) + .handle((b, sink) -> { + if (b.length > MAX_UPLOAD_BYTES) { + sink.error(new IllegalArgumentException("arquivo excede 50 MB")); + } else { + sink.next(b); + } + }); + } + + private static byte[] concat(byte[] a, byte[] buf) { + byte[] out = new byte[a.length + buf.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(buf, 0, out, a.length, buf.length); + return out; + } +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/ConsultaController.java b/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/ConsultaController.java new file mode 100644 index 0000000..5a256ae --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/ConsultaController.java @@ -0,0 +1,31 @@ +package com.portabilidade.bff.interfaces.rest; + +import com.portabilidade.bff.application.ConsultaService; +import jakarta.validation.constraints.NotEmpty; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +/** Endpoint da tela de consulta (orientado a tela — convenção BFF). */ +@RestController +@RequestMapping("/bff/v1") +public class ConsultaController { + + private final ConsultaService consultaService; + + public ConsultaController(ConsultaService consultaService) { + this.consultaService = consultaService; + } + + public record ConsultaRequest(@NotEmpty List numeros) { } + + @PostMapping("/consulta") + public Mono> consultar(@RequestBody ConsultaRequest request) { + return consultaService.consultar(request.numeros()); + } +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/DashboardController.java b/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/DashboardController.java new file mode 100644 index 0000000..6d0f3f9 --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/DashboardController.java @@ -0,0 +1,32 @@ +package com.portabilidade.bff.interfaces.rest; + +import com.portabilidade.bff.application.DashboardService; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +/** Endpoint da tela de dashboard: agrega métricas das consultas informadas pela UI. */ +@RestController +@RequestMapping("/bff/v1/dashboard") +public class DashboardController { + + private final DashboardService dashboardService; + + public DashboardController(DashboardService dashboardService) { + this.dashboardService = dashboardService; + } + + public record ResumoRequest(List> consultas) { } + + @PostMapping("/resumo") + public Mono> resumo(@RequestBody ResumoRequest request) { + List> consultas = + request.consultas() == null ? List.of() : request.consultas(); + return dashboardService.resumo(consultas); + } +} diff --git a/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/OperadorasController.java b/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/OperadorasController.java new file mode 100644 index 0000000..2e119e9 --- /dev/null +++ b/portability-bff/src/main/java/com/portabilidade/bff/interfaces/rest/OperadorasController.java @@ -0,0 +1,27 @@ +package com.portabilidade.bff.interfaces.rest; + +import com.portabilidade.bff.application.ApiClientPort; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +/** Dicionário de operadoras para selects/autocomplete da UI (proxy da API core). */ +@RestController +@RequestMapping("/bff/v1") +public class OperadorasController { + + private final ApiClientPort api; + + public OperadorasController(ApiClientPort api) { + this.api = api; + } + + @GetMapping("/operadoras") + public Mono>> operadoras() { + return api.operadoras(); + } +} diff --git a/portability-bff/src/main/resources/application.yml b/portability-bff/src/main/resources/application.yml new file mode 100644 index 0000000..2b7b927 --- /dev/null +++ b/portability-bff/src/main/resources/application.yml @@ -0,0 +1,20 @@ +server: + port: 8081 + compression: + enabled: true + +spring: + codec: + max-in-memory-size: 64MB + threads: + virtual: + enabled: true # Netty + handlers bloqueantes em virtual threads (Java 21) + +bff: + api: + base-url: ${BFF_API_BASE_URL:http://localhost:8080} + timeout-seconds: 60 + +springdoc: + swagger-ui: + path: /swagger-ui.html diff --git a/portability-bff/src/test/java/com/portabilidade/bff/application/BatchServiceTest.java b/portability-bff/src/test/java/com/portabilidade/bff/application/BatchServiceTest.java new file mode 100644 index 0000000..2f1972d --- /dev/null +++ b/portability-bff/src/test/java/com/portabilidade/bff/application/BatchServiceTest.java @@ -0,0 +1,24 @@ +package com.portabilidade.bff.application; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BatchServiceTest { + + @Test + void computesProgressPercent() { + Map ui = BatchService.toUi(Map.of( + "status", "RUNNING", "linhasProcessadas", 25_000L, "totalLinhas", 50_000L)); + assertEquals(50L, ((Number) ui.get("progressoPct")).longValue()); + } + + @Test + void zeroWhenTotalUnknown() { + Map ui = BatchService.toUi(Map.of( + "status", "PENDING", "linhasProcessadas", 0L, "totalLinhas", -1L)); + assertEquals(0L, ((Number) ui.get("progressoPct")).longValue()); + } +} diff --git a/portability-bff/src/test/java/com/portabilidade/bff/application/ConsultaServiceTest.java b/portability-bff/src/test/java/com/portabilidade/bff/application/ConsultaServiceTest.java new file mode 100644 index 0000000..c8b4873 --- /dev/null +++ b/portability-bff/src/test/java/com/portabilidade/bff/application/ConsultaServiceTest.java @@ -0,0 +1,76 @@ +package com.portabilidade.bff.application; + +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ConsultaServiceTest { + + /** Fake do port: devolve itens posicionalmente iguais aos recebidos. */ + private static ApiClientPort fakeApi(List> received) { + return new ApiClientPort() { + @Override + public Mono> consultar(List numeros) { + received.add(new ArrayList<>(numeros)); + List> itens = new ArrayList<>(); + for (String n : numeros) { + itens.add(Map.of( + "numero", n, + "canonical", n, + "operadora", n.startsWith("11") ? "VIVO" : "", + "fonte", "DUCKDB")); + } + return Mono.just(Map.of("itens", itens)); + } + @Override + public Mono> submitBatch(byte[] c, String f, String t) { return Mono.empty(); } + @Override + public Mono> batchStatus(String id) { return Mono.empty(); } + @Override + public Mono batchResultCsv(String id) { return Mono.empty(); } + @Override + public Mono>> operadoras() { return Mono.just(List.of()); } + }; + } + + @Test + void dedupesAndChunksBy100() { + List> received = new ArrayList<>(); + ConsultaService svc = new ConsultaService(fakeApi(received)); + List input = new ArrayList<>(); + for (int i = 0; i < 250; i++) input.add("1190000000" + i % 200); // 200 únicos + duplicados + + StepVerifier.create(svc.consultar(input)) + .assertNext(out -> { + assertEquals(200, out.get("total")); + assertEquals(200L, ((Number) out.get("encontradas")).longValue()); + }) + .verifyComplete(); + assertEquals(2, received.size()); // 200 únicos → chunks 100+100 + assertEquals(100, received.get(0).size()); + assertEquals(100, received.get(1).size()); + } + + @Test + void rejectsMoreThanMax() { + ConsultaService svc = new ConsultaService(fakeApi(new ArrayList<>())); + List input = new ArrayList<>(); + for (int i = 0; i < ConsultaService.MAX_NUMEROS + 1; i++) input.add("11" + i); + StepVerifier.create(svc.consultar(input)) + .expectError(IllegalArgumentException.class) + .verify(); + } + + @Test + void masksOperatorForUi() { + assertEquals("VIV…", ConsultaService.mask("VIVO S.A.")); + assertEquals("TIM", ConsultaService.mask("TIM")); + assertEquals(null, ConsultaService.mask(" ")); + } +} diff --git a/portability-bff/src/test/java/com/portabilidade/bff/application/DashboardServiceTest.java b/portability-bff/src/test/java/com/portabilidade/bff/application/DashboardServiceTest.java new file mode 100644 index 0000000..e99d2f3 --- /dev/null +++ b/portability-bff/src/test/java/com/portabilidade/bff/application/DashboardServiceTest.java @@ -0,0 +1,39 @@ +package com.portabilidade.bff.application; + +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; + +import java.util.List; +import java.util.Map; + +class DashboardServiceTest { + + @Test + void aggregatesRateTopOperatorsAndSources() { + DashboardService svc = new DashboardService(); + var consultas = List.of( + Map.of("numero", "1", "operadora", "VIVO", "fonte", "DUCKDB"), + Map.of("numero", "2", "operadora", "VIVO", "fonte", "JSON"), + Map.of("numero", "3", "operadora", "", "fonte", "NONE")); + + StepVerifier.create(svc.resumo(consultas)) + .assertNext(out -> { + assert out.get("totalConsultas").equals(3L); + assert out.get("resolvidas").equals(2L); + assert out.get("taxaAcerto").equals(66.67); + List top = (List) out.get("topOperadoras"); + assert !top.isEmpty(); + }) + .verifyComplete(); + } + + @Test + void emptyInputYieldsZeroRate() { + StepVerifier.create(new DashboardService().resumo(List.of())) + .assertNext(out -> { + assert out.get("totalConsultas").equals(0L); + assert out.get("taxaAcerto").equals(0.0); + }) + .verifyComplete(); + } +} diff --git a/portability-bff/src/test/java/com/portabilidade/bff/interfaces/rest/BffControllersTest.java b/portability-bff/src/test/java/com/portabilidade/bff/interfaces/rest/BffControllersTest.java new file mode 100644 index 0000000..9f3f2ae --- /dev/null +++ b/portability-bff/src/test/java/com/portabilidade/bff/interfaces/rest/BffControllersTest.java @@ -0,0 +1,102 @@ +package com.portabilidade.bff.interfaces.rest; + +import com.portabilidade.bff.application.ApiClientPort; +import com.portabilidade.bff.application.BatchService; +import com.portabilidade.bff.application.ConsultaService; +import com.portabilidade.bff.application.DashboardService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +class BffControllersTest { + + private WebTestClient client; + + private static final ApiClientPort FAKE_API = new ApiClientPort() { + @Override + public Mono> consultar(List numeros) { + return Mono.just(Map.of("itens", numeros.stream() + .>map(n -> Map.of( + "numero", n, "canonical", n, + "operadora", "VIVO", "fonte", "DUCKDB")) + .toList())); + } + @Override + public Mono> submitBatch(byte[] c, String f, String t) { + return Mono.just(Map.of("id", "job-1", "status", "PENDING")); + } + @Override + public Mono> batchStatus(String id) { + return Mono.just(Map.of("id", id, "status", "COMPLETED", + "linhasProcessadas", 10L, "totalLinhas", 10L)); + } + @Override + public Mono batchResultCsv(String id) { + return Mono.just("telefone,operadora\n".getBytes()); + } + @Override + public Mono>> operadoras() { + return Mono.just(List.of(Map.of("codigo", "123", "nome", "VIVO"))); + } + }; + + @BeforeEach + void setUp() { + client = WebTestClient.bindToController( + new ConsultaController(new ConsultaService(FAKE_API)), + new BatchController(new BatchService(FAKE_API)), + new DashboardController(new DashboardService()), + new OperadorasController(FAKE_API)) + .build(); + } + + @Test + void consultaReturnsFormattedItems() { + client.post().uri("/bff/v1/consulta") + .bodyValue(Map.of("numeros", List.of("11999990000", "11999990000"))) + .exchange().expectStatus().isOk() + .expectBody() + .jsonPath("$.total").isEqualTo(1) // dedup no BFF + .jsonPath("$.encontradas").isEqualTo(1) + .jsonPath("$.itens[0].mascarado").isEqualTo("VIV…"); + } + + @Test + void batchStatusSinglePollWorks() { + client.get().uri("/bff/v1/batch/job-1") + .exchange().expectStatus().isOk() + .expectBody() + .jsonPath("$.status").isEqualTo("COMPLETED") + .jsonPath("$.progressoPct").isEqualTo(100); + } + + @Test + void dashboardResumoAggregates() { + client.post().uri("/bff/v1/dashboard/resumo") + .bodyValue(Map.of("consultas", List.of( + Map.of("operadora", "VIVO", "fonte", "DUCKDB")))) + .exchange().expectStatus().isOk() + .expectBody() + .jsonPath("$.totalConsultas").isEqualTo(1) + .jsonPath("$.taxaAcerto").isEqualTo(100.0); + } + + @Test + void operadorasProxied() { + client.get().uri("/bff/v1/operadoras") + .exchange().expectStatus().isOk() + .expectBody() + .jsonPath("$[0].nome").isEqualTo("VIVO"); + } + + @Test + void resultadoDownloadCsv() { + client.get().uri("/bff/v1/batch/job-1/resultado") + .exchange().expectStatus().isOk() + .expectHeader().contentTypeCompatibleWith("text/csv"); + } +}