Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -152,14 +152,13 @@
<scope>test</scope>
</dependency>

<!-- Valida o payload publicado contra o schema real em contracts/events/,
<!-- Valida especificações recebidas e payloads de eventos contra os schemas canônicos,
Jackson 2 (com.fasterxml), sem conflito com o Jackson 3 (tools.jackson)
usado pelo restante da aplicação. -->
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>${json-schema-validator.version}</version>
<scope>test</scope>
</dependency>
</dependencies>

Expand Down
17 changes: 10 additions & 7 deletions api/src/main/java/synapse/api/job/BuscarJobService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.util.UUID;

import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

Expand All @@ -19,7 +20,9 @@ class BuscarJobService {

private final JdbcTemplate jdbc;

private final JsonMapper json = new JsonMapper();
private final JsonMapper json = JsonMapper.builder()
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
.build();

BuscarJobService(JdbcTemplate jdbc) {
this.jdbc = jdbc;
Expand All @@ -31,13 +34,14 @@ JobDetalhadoDto buscar(UUID jobId) {
SELECT
j.id,
j.status,
s.tipo AS origem,
CASE WHEN j.job_origem_id IS NOT NULL THEN 'reprocessamento' ELSE s.tipo END AS origem,
j.competencias,
j.orcamento,
j.criado_em,
j.iniciado_em,
j.finalizado_em,
j.submissao_id,
j.job_origem_id,
regras.regras,

sim.id AS simulacao_id,
Expand All @@ -51,7 +55,7 @@ JobDetalhadoDto buscar(UUID jobId) {
rs.decomposicao AS simulacao_decomposicao

FROM jobs j
JOIN submissoes s ON s.id = j.submissao_id
LEFT JOIN submissoes s ON s.id = j.submissao_id
JOIN LATERAL (
SELECT jsonb_agg(
jsonb_build_object(
Expand Down Expand Up @@ -87,6 +91,7 @@ LEFT JOIN LATERAL (
Instant finalizadoEm = rs.getTimestamp("finalizado_em") != null
? rs.getTimestamp("finalizado_em").toInstant() : null;
UUID submissaoId = rs.getObject("submissao_id", UUID.class);
UUID jobOrigemId = rs.getObject("job_origem_id", UUID.class);

List<RegraCriadaDto> regras = regras(rs.getString("regras"));

Expand Down Expand Up @@ -125,7 +130,7 @@ LEFT JOIN LATERAL (
}

return new JobDetalhadoDto(id, status, origem, competencias, orcamento, criadoEm, iniciadoEm,
finalizadoEm, submissaoId, regras, simulacao);
finalizadoEm, submissaoId, jobOrigemId, regras, simulacao);
}, jobId);
}
catch (EmptyResultDataAccessException ex) {
Expand All @@ -138,9 +143,7 @@ private List<RegraCriadaDto> regras(String regrasJson) {
List<RegraCriadaDto> regras = new ArrayList<>();
for (JsonNode regra : raiz) {
NucleoRegraDto nucleo = this.json.readValue(regra.path("nucleo").toString(), NucleoRegraDto.class);
List<EspecificacaoRegraDto> especificacoes = this.json.readValue(regra.path("especificacoes").toString(),
new TypeReference<List<EspecificacaoRegraDto>>() {
});
List<JsonNode> especificacoes = regra.path("especificacoes").valueStream().toList();
RepresentacaoRegraDto representacao = new RepresentacaoRegraDto(nucleo, especificacoes);
regras.add(new RegraCriadaDto(UUID.fromString(regra.path("id").asString()), regra.path("versao").asInt(),
regra.path("origem").asString(), representacao, Instant.parse(regra.path("criada_em").asString())));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
import java.util.HashSet;
import java.util.List;

import com.networknt.schema.InputFormat;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SchemaLocation;
import com.networknt.schema.SchemaValidatorsConfig;
import com.networknt.schema.SpecVersion.VersionFlag;
import org.jspecify.annotations.Nullable;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.DeserializationFeature;
Expand All @@ -24,6 +30,13 @@ record ConfirmarParametrosRequisicao(RepresentacaoRegraDto representacao, @Nulla

private static final List<String> CAMPOS_NUCLEO = List.of("vigencia", "loja", "marca", "cargo", "percentual");

private static final JsonSchema ESPECIFICACOES = JsonSchemaFactory
.getInstance(VersionFlag.V202012,
builder -> builder.schemaMappers(mappers -> mappers.mapPrefix("https://synapse.local/contracts/domain/",
"classpath:static/openapi/domain/")))
.getSchema(SchemaLocation.of("classpath:static/openapi/domain/regra-especificacoes.schema.json"),
SchemaValidatorsConfig.builder().formatAssertionsEnabled(true).build());

private static final JsonMapper JSON = JsonMapper.builder()
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, DeserializationFeature.FAIL_ON_TRAILING_TOKENS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
Expand All @@ -47,7 +60,8 @@ static ConfirmarParametrosRequisicao deJson(String corpo) {
validarNucleo(regra.path("nucleo"));
validarEspecificacoes(regra.path("especificacoes"));
RepresentacaoRegraDto representacao = new RepresentacaoRegraDto(
JSON.treeToValue(regra.path("nucleo"), NucleoRegraDto.class), List.of());
JSON.treeToValue(regra.path("nucleo"), NucleoRegraDto.class),
regra.path("especificacoes").valueStream().toList());
return new ConfirmarParametrosRequisicao(representacao, orcamento(raiz.path("orcamento")), competencias(raiz));
}

Expand Down Expand Up @@ -96,12 +110,10 @@ private static void validarNucleo(JsonNode nucleo) {
}

private static void validarEspecificacoes(JsonNode especificacoes) {
if (especificacoes.isMissingNode() || especificacoes.isNull()) {
return;
}
if (!especificacoes.isArray() || !especificacoes.isEmpty()) {
if (!especificacoes.isArray()
|| !ESPECIFICACOES.validate(especificacoes.toString(), InputFormat.JSON).isEmpty()) {
throw ConfirmarParametrosException
.requisicao("Na Sprint 1, regra.especificacoes deve ser uma lista vazia.");
.requisicao("O campo regra.especificacoes deve ser uma lista de elementos válidos da regra.");
}
}

Expand Down
75 changes: 52 additions & 23 deletions api/src/main/java/synapse/api/job/ConfirmarParametrosService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;

import org.jspecify.annotations.Nullable;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

import org.springframework.dao.EmptyResultDataAccessException;
Expand Down Expand Up @@ -40,7 +43,9 @@ class ConfirmarParametrosService {

private final Outbox outbox;

private final JsonMapper json = new JsonMapper();
private final JsonMapper json = JsonMapper.builder()
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
.build();

ConfirmarParametrosService(JdbcTemplate jdbc, MaquinaDeEstadosDoJob maquina, Outbox outbox) {
this.jdbc = jdbc;
Expand All @@ -67,25 +72,26 @@ JobCriadoDto confirmar(UUID jobId, ConfirmarParametrosRequisicao requisicao) {

this.outbox.registrar(jobId, EventoOutbox.PARAMETROS_CONFIRMADOS,
new ParametrosConfirmadosDto(jobId, versao.id()));
registrarTrilha(jobId, versao.id(), editado, representacao.nucleo(), anterior, timestamp);
registrarTrilha(jobId, versao.id(), editado, representacao, anterior, timestamp);

RegraCriadaDto regra = new RegraCriadaDto(versao.id(), versao.versao(), "confirmacao_usuario", representacao,
RegraCriadaDto regra = new RegraCriadaDto(versao.id(), versao.versao(), versao.origem(), representacao,
versao.criadaEm());
return new JobCriadoDto(jobId, JobStatus.GERANDO_REGRA.paraColuna(), dados.origem(), competencias, orcamento,
dados.criadoEm(), dados.submissaoId(), regra);
dados.criadoEm(), dados.submissaoId(), dados.jobOrigemId(), regra);
}

private DadosDoJob carregarJob(UUID jobId) {
try {
return Objects.requireNonNull(this.jdbc.queryForObject("""
SELECT s.tipo AS origem, j.orcamento, j.criado_em, j.submissao_id
FROM jobs j JOIN submissoes s ON s.id = j.submissao_id
SELECT CASE WHEN j.job_origem_id IS NOT NULL THEN 'reprocessamento' ELSE s.tipo END AS origem,
j.orcamento, j.criado_em, j.submissao_id, j.job_origem_id
FROM jobs j LEFT JOIN submissoes s ON s.id = j.submissao_id
WHERE j.id = ?
""",
(rs, linha) -> new DadosDoJob(Objects.requireNonNull(rs.getString("origem")),
Objects.requireNonNull(rs.getBigDecimal("orcamento")),
Objects.requireNonNull(rs.getTimestamp("criado_em")).toInstant(),
Objects.requireNonNull(rs.getObject("submissao_id", UUID.class))),
rs.getObject("submissao_id", UUID.class), rs.getObject("job_origem_id", UUID.class)),
jobId));
}
catch (EmptyResultDataAccessException ex) {
Expand All @@ -94,23 +100,28 @@ private DadosDoJob carregarJob(UUID jobId) {
}

private @Nullable VersaoAnterior ultimaVersao(UUID jobId) {
List<VersaoAnterior> versoes = this.jdbc.query("""
SELECT id, versao, hash, nucleo FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1
""",
List<VersaoAnterior> versoes = this.jdbc.query(
"""
SELECT id, versao, hash, nucleo, especificacoes FROM regras WHERE job_id = ? ORDER BY versao DESC LIMIT 1
""",
(rs, linha) -> new VersaoAnterior(Objects.requireNonNull(rs.getObject("id", UUID.class)),
rs.getInt("versao"), Objects.requireNonNull(rs.getString("hash")),
this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class)),
this.json.readValue(Objects.requireNonNull(rs.getString("nucleo")), NucleoRegraDto.class),
this.json.readTree(Objects.requireNonNull(rs.getString("especificacoes")))
.valueStream()
.toList()),
jobId);
return versoes.isEmpty() ? null : versoes.getFirst();
}

private VersaoRegra resolverVersao(UUID jobId, RepresentacaoRegraDto representacao, String hash,
@Nullable VersaoAnterior anterior, Timestamp timestamp, Instant agora) {
List<VersaoRegra> existentes = this.jdbc.query("""
SELECT id, versao, criada_em FROM regras WHERE job_id = ? AND hash = ?
SELECT id, versao, origem, criada_em FROM regras WHERE job_id = ? AND hash = ?
""",
(rs, linha) -> new VersaoRegra(Objects.requireNonNull(rs.getObject("id", UUID.class)),
rs.getInt("versao"), Objects.requireNonNull(rs.getTimestamp("criada_em")).toInstant()),
rs.getInt("versao"), Objects.requireNonNull(rs.getString("origem")),
Objects.requireNonNull(rs.getTimestamp("criada_em")).toInstant()),
jobId, hash);
if (!existentes.isEmpty()) {
return existentes.getFirst();
Expand All @@ -119,10 +130,10 @@ private VersaoRegra resolverVersao(UUID jobId, RepresentacaoRegraDto representac
UUID origemId = (anterior != null) ? anterior.id() : null;
UUID id = Objects.requireNonNull(this.jdbc.queryForObject("""
INSERT INTO regras (job_id, versao, origem, regra_origem_id, nucleo, especificacoes, hash, criada_em)
VALUES (?, ?, 'confirmacao_usuario', ?, ?::jsonb, '[]'::jsonb, ?, ?) RETURNING id
VALUES (?, ?, 'confirmacao_usuario', ?, ?::jsonb, ?::jsonb, ?, ?) RETURNING id
""", UUID.class, jobId, novaVersao, origemId, this.json.writeValueAsString(representacao.nucleo()),
hash, timestamp));
return new VersaoRegra(id, novaVersao, agora);
this.json.writeValueAsString(representacao.especificacoes()), hash, timestamp));
return new VersaoRegra(id, novaVersao, "confirmacao_usuario", agora);
}

private BigDecimal resolverOrcamento(UUID jobId, ConfirmarParametrosRequisicao requisicao, BigDecimal atual) {
Expand All @@ -144,11 +155,15 @@ private List<String> resolverCompetencias(UUID jobId, ConfirmarParametrosRequisi
return this.jdbc.queryForList("SELECT unnest(competencias) FROM jobs WHERE id = ?", String.class, jobId);
}

private void registrarTrilha(UUID jobId, UUID regraId, boolean editado, NucleoRegraDto atual,
private void registrarTrilha(UUID jobId, UUID regraId, boolean editado, RepresentacaoRegraDto atual,
@Nullable VersaoAnterior anterior, Timestamp timestamp) {
List<String> corrigidos = (anterior != null) ? camposCorrigidos(atual, anterior.nucleo()) : List.of();
String resumo = (editado && !corrigidos.isEmpty())
? "usuário corrigiu " + String.join(", ", corrigidos) + " antes de confirmar"
List<String> corrigidos = (anterior != null && editado) ? camposCorrigidos(atual.nucleo(), anterior.nucleo())
: new ArrayList<>();
if (anterior != null && editado) {
corrigidos.addAll(especificacoesCorrigidas(atual.especificacoes(), anterior.especificacoes()));
}
String resumo = editado ? "usuário corrigiu "
+ (corrigidos.isEmpty() ? "a representação" : String.join(", ", corrigidos)) + " antes de confirmar"
: "usuário confirmou os parâmetros";
Map<String, Object> conclusao = new LinkedHashMap<>();
conclusao.put("resumo", resumo);
Expand Down Expand Up @@ -189,13 +204,27 @@ private static boolean percentualDiferente(@Nullable BigDecimal atual, @Nullable
return atual.compareTo(anterior) != 0;
}

private record DadosDoJob(String origem, BigDecimal orcamento, Instant criadoEm, UUID submissaoId) {
private static List<String> especificacoesCorrigidas(List<JsonNode> atuais, List<JsonNode> anteriores) {
var refs = new LinkedHashSet<String>();
atuais.forEach(elemento -> refs.add(elemento.path("ref").asString()));
anteriores.forEach(elemento -> refs.add(elemento.path("ref").asString()));
return refs.stream()
.filter(ref -> !atuais.stream()
.filter(elemento -> ref.equals(elemento.path("ref").asString()))
.toList()
.equals(anteriores.stream().filter(elemento -> ref.equals(elemento.path("ref").asString())).toList()))
.toList();
}

private record DadosDoJob(String origem, BigDecimal orcamento, Instant criadoEm, @Nullable UUID submissaoId,
@Nullable UUID jobOrigemId) {
}

private record VersaoAnterior(UUID id, int versao, String hash, NucleoRegraDto nucleo) {
private record VersaoAnterior(UUID id, int versao, String hash, NucleoRegraDto nucleo,
List<JsonNode> especificacoes) {
}

private record VersaoRegra(UUID id, int versao, Instant criadaEm) {
private record VersaoRegra(UUID id, int versao, String origem, Instant criadaEm) {
}

}
Expand Down
2 changes: 1 addition & 1 deletion api/src/main/java/synapse/api/job/CriarJobService.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ INSERT INTO regras (job_id, versao, origem, nucleo, especificacoes, hash, criada
this.outbox.registrar(jobId, EventoOutbox.REGRA_SUBMETIDA,
new RegraSubmetidaDto(jobId, requisicao.origem(), requisicao.competencias(), submissaoId, regraId));
return new JobCriadoDto(jobId, status, requisicao.origem(), requisicao.competencias(), requisicao.orcamento(),
agora, submissaoId, regra);
agora, submissaoId, null, regra);
}

}
2 changes: 2 additions & 0 deletions api/src/main/java/synapse/api/job/HashDaRegra.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import tools.jackson.core.StreamWriteFeature;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.cfg.JsonNodeFeature;
import tools.jackson.databind.json.JsonMapper;

final class HashDaRegra {
Expand All @@ -16,6 +17,7 @@ final class HashDaRegra {
.enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY)
.disable(MapperFeature.SORT_CREATOR_PROPERTIES_FIRST)
.enable(StreamWriteFeature.WRITE_BIGDECIMAL_AS_PLAIN)
.enable(JsonNodeFeature.WRITE_PROPERTIES_SORTED)
.build();

private HashDaRegra() {
Expand Down
10 changes: 7 additions & 3 deletions api/src/main/java/synapse/api/job/JobCriadoDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import com.fasterxml.jackson.annotation.JsonInclude;
import org.jspecify.annotations.Nullable;

record JobCriadoDto(UUID id, String status, String origem, List<String> competencias, BigDecimal orcamento,
Instant criado_em, UUID submissao_id, RegraCriadaDto regra) {
Instant criado_em, @JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID submissao_id,
@JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID job_origem_id, RegraCriadaDto regra) {
}

record RegraCriadaDto(UUID id, int versao, String origem, RepresentacaoRegraDto representacao, Instant criada_em) {
Expand All @@ -18,6 +20,8 @@ record SimulacaoDto(UUID id, Instant criado_em, @Nullable String status, @Nullab
}

record JobDetalhadoDto(UUID id, String status, String origem, List<String> competencias, BigDecimal orcamento,
Instant criado_em, @Nullable Instant iniciado_em, @Nullable Instant finalizado_em, UUID submissao_id,
List<RegraCriadaDto> regras, @Nullable SimulacaoDto simulacao) {
Instant criado_em, @Nullable Instant iniciado_em, @Nullable Instant finalizado_em,
@JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID submissao_id,
@JsonInclude(JsonInclude.Include.NON_NULL) @Nullable UUID job_origem_id, List<RegraCriadaDto> regras,
@Nullable SimulacaoDto simulacao) {
}
34 changes: 34 additions & 0 deletions api/src/main/java/synapse/api/job/ReprocessarJobAdvice.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package synapse.api.job;

import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice(assignableTypes = ReprocessarJobController.class)
class ReprocessarJobAdvice {

@ExceptionHandler(ReprocessarJobException.class)
ResponseEntity<ErroDto> tratar(ReprocessarJobException ex) {
return ResponseEntity.status(ex.status()).body(ex.erro());
}

@ExceptionHandler(JobNaoEncontradoException.class)
ResponseEntity<ErroDto> jobNaoEncontrado() {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErroDto("job_nao_encontrado", "Job não encontrado."));
}

@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<ErroDto> corpoInvalido() {
return tratar(ReprocessarJobException.requisicao("O corpo deve conter um objeto JSON válido."));
}

@ExceptionHandler(DataAccessException.class)
ResponseEntity<Void> falhaDePersistencia() {
return ResponseEntity.internalServerError().build();
}

}
Loading
Loading