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
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.decision.store.api

import com.fasterxml.jackson.databind.ObjectMapper
import com.fincore.decision.store.persistence.DecisionLogRepository
import com.fincore.test.containers.PostgresContainerExtension
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.boot.test.web.client.TestRestTemplate
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Import
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import java.time.Instant
import java.util.UUID

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(PostgresContainerExtension::class)
@Import(DecisionReplayApiIT.TestSecurity::class)
class DecisionReplayApiIT(
@Autowired private val rest: TestRestTemplate,
@Autowired private val objectMapper: ObjectMapper,
@Autowired private val decisionLogRepository: DecisionLogRepository,
) {
private val matchRule = """{"condition":{"attr":"amount","op":"gte","value":100},"outcome":{"label":"approve"}}"""
private val candidateRule = """{"condition":{"attr":"amount","op":"gte","value":1000},"outcome":{"label":"approve"}}"""
private val redosRule = """{"condition":{"attr":"s","op":"matches","value":"(.*,){1,100}Z"},"outcome":{"label":"x"}}"""

@TestConfiguration
class TestSecurity {
@Bean
fun jwtDecoder(): JwtDecoder =
JwtDecoder { token ->
val scope = if (token == WRITER) "decision:read decision:write" else "decision:read"
Jwt
.withTokenValue(token)
.header("alg", "none")
.subject("api-user")
.claim("scope", scope)
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(EXPIRY_SECONDS))
.build()
}
}

@Test
fun `should diff a candidate against a recorded decision and write nothing`() {
val key = newKey()
publishRule(key, matchRule)
evaluate(key, """{"amount":150}""").statusCode shouldBe HttpStatus.OK
val auditCountBefore = decisionLogRepository.count()

val response = replay("""{"candidate":$candidateRule,"inputs":[{"amount":150}]}""")

response.statusCode shouldBe HttpStatus.OK
val report = objectMapper.readTree(response.body)
report.get("changed").asInt() shouldBe 1
report
.get("diffs")
.get(0)
.get("status")
.asText() shouldBe "CHANGED"
decisionLogRepository.count() shouldBe auditCountBefore
}

@Test
fun `should return no baseline for an input never evaluated`() {
val response = replay("""{"candidate":$matchRule,"inputs":[{"amount":${UUID.randomUUID().hashCode()}}]}""")

response.statusCode shouldBe HttpStatus.OK
objectMapper.readTree(response.body).get("noBaseline").asInt() shouldBe 1
}

@Test
fun `should return 503 when a candidate pattern catastrophically backtracks`() {
val response = replay("""{"candidate":$redosRule,"inputs":[{"s":"${"a,".repeat(REDOS_REPEATS)}"}]}""")

response.statusCode shouldBe HttpStatus.SERVICE_UNAVAILABLE
}

@Test
fun `should reject replay carrying only the read scope`() {
val response =
rest.exchange(
"/v1/decision/replay",
HttpMethod.POST,
HttpEntity("""{"candidate":$matchRule,"inputs":[{"amount":1}]}""", headers(READER)),
String::class.java,
)

response.statusCode shouldBe HttpStatus.FORBIDDEN
}

private fun publishRule(
key: String,
dsl: String,
) {
rest
.postForEntity("/v1/decision/rules", HttpEntity("""{"ruleKey":"$key"}""", headers(WRITER)), String::class.java)
.statusCode shouldBe HttpStatus.CREATED
rest
.postForEntity("/v1/decision/rules/$key/versions", HttpEntity(dsl, headers(WRITER)), String::class.java)
.statusCode shouldBe HttpStatus.CREATED
}

private fun evaluate(
key: String,
input: String,
) = rest.postForEntity("/v1/decision/rules/$key/evaluate", HttpEntity(input, headers(WRITER)), String::class.java)

private fun replay(body: String) = rest.postForEntity("/v1/decision/replay", HttpEntity(body, headers(WRITER)), String::class.java)

private fun headers(token: String): HttpHeaders =
HttpHeaders().apply {
contentType = MediaType.APPLICATION_JSON
setBearerAuth(token)
}

private fun newKey(): String = "rule-${UUID.randomUUID()}"

companion object {
private const val WRITER = "writer"
private const val READER = "reader"
private const val EXPIRY_SECONDS = 300L
private const val REDOS_REPEATS = 25

@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { PostgresContainerExtension.jdbcUrl }
registry.add("spring.datasource.username") { PostgresContainerExtension.username }
registry.add("spring.datasource.password") { PostgresContainerExtension.password }
registry.add("spring.jpa.hibernate.ddl-auto") { "none" }
registry.add("fincore.decision.api.evaluation-timeout-millis") { "100" }
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.decision.store.api

import com.fasterxml.jackson.databind.ObjectMapper
import com.fincore.decision.store.api.dto.request.ReplayRequest
import com.fincore.decision.store.api.dto.response.ReplayReportResponse
import com.fincore.decision.store.api.mapper.DecisionApiMapper
import com.fincore.decision.store.application.ReplayService
import io.swagger.v3.oas.annotations.Operation
import io.swagger.v3.oas.annotations.tags.Tag
import jakarta.validation.Valid
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

@Tag(name = "Decision replay", description = "Diff a candidate ruleset against recorded decisions for historical inputs")
@RestController
@RequestMapping("/v1/decision/replay")
class ReplayController(
private val replayService: ReplayService,
private val mapper: DecisionApiMapper,
private val objectMapper: ObjectMapper,
) {
@Operation(
summary = "Replay a candidate ruleset",
description = "Evaluates a candidate against each input and diffs the outcome against the recorded decision. Writes nothing.",
)
@PostMapping
fun replay(
@Valid @RequestBody request: ReplayRequest,
): ReplayReportResponse = mapper.toResponse(replayService.replay(objectMapper.writeValueAsString(request.candidate), request.inputs))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.decision.store.api.dto.request

import com.fasterxml.jackson.annotation.JsonSetter
import com.fasterxml.jackson.annotation.Nulls
import com.fasterxml.jackson.databind.JsonNode
import jakarta.validation.constraints.NotEmpty
import jakarta.validation.constraints.NotNull

data class ReplayRequest(
@field:NotNull
@JsonSetter(nulls = Nulls.FAIL)
val candidate: JsonNode,
@field:NotEmpty
@JsonSetter(contentNulls = Nulls.FAIL)
val inputs: List<Map<String, JsonNode>>,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.decision.store.api.dto.response

data class ReplayReportResponse(
val total: Int,
val unchanged: Int,
val changed: Int,
val noBaseline: Int,
val diffs: List<ReplayDiffResponse>,
)

data class ReplayDiffResponse(
val inputHash: String,
val recorded: OutcomeSummaryResponse?,
val candidate: OutcomeSummaryResponse,
val status: String,
)

data class OutcomeSummaryResponse(
val matched: Boolean,
val outcomeLabel: String?,
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ import com.fincore.decision.store.api.dto.response.ActiveVersionResponse
import com.fincore.decision.store.api.dto.response.DecisionLogResponse
import com.fincore.decision.store.api.dto.response.DecisionResponse
import com.fincore.decision.store.api.dto.response.OutcomeResponse
import com.fincore.decision.store.api.dto.response.OutcomeSummaryResponse
import com.fincore.decision.store.api.dto.response.ReplayDiffResponse
import com.fincore.decision.store.api.dto.response.ReplayReportResponse
import com.fincore.decision.store.api.dto.response.RuleDetailResponse
import com.fincore.decision.store.api.dto.response.RuleResponse
import com.fincore.decision.store.api.dto.response.VersionResponse
import com.fincore.decision.store.application.DecisionLogView
import com.fincore.decision.store.application.EvaluationOutcome
import com.fincore.decision.store.application.ReplayDiff
import com.fincore.decision.store.application.ReplayReport
import com.fincore.decision.store.application.RuleDetailView
import com.fincore.decision.store.application.RuleView
import com.fincore.decision.store.application.VersionView
Expand Down Expand Up @@ -56,4 +61,21 @@ class DecisionApiMapper(
matched = view.matched,
outcomeLabel = view.outcomeLabel,
)

fun toResponse(report: ReplayReport): ReplayReportResponse =
ReplayReportResponse(
total = report.total,
unchanged = report.unchanged,
changed = report.changed,
noBaseline = report.noBaseline,
diffs = report.diffs.map(::toDiffResponse),
)

private fun toDiffResponse(diff: ReplayDiff): ReplayDiffResponse =
ReplayDiffResponse(
inputHash = diff.inputHash,
recorded = diff.recordedMatched?.let { OutcomeSummaryResponse(it, diff.recordedLabel) },
candidate = OutcomeSummaryResponse(diff.candidateMatched, diff.candidateLabel),
status = diff.status.name,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,7 @@
package com.fincore.decision.store.application

import com.fasterxml.jackson.databind.JsonNode
import com.fincore.decision.domain.AttrValue
import com.fincore.decision.domain.BoolValue
import com.fincore.decision.domain.DecimalValue
import com.fincore.decision.domain.EvaluationInput
import com.fincore.decision.domain.StringValue
import com.fincore.decision.parser.RuleParser
import com.fincore.decision.store.config.DecisionApiProperties
import com.fincore.decision.store.exception.InputNotMappableException
import com.fincore.decision.store.exception.InputTooLargeException
import com.fincore.decision.store.exception.RuleNotActiveException
import com.fincore.decision.store.exception.RuleNotFoundException
import com.fincore.decision.store.persistence.DecisionRuleRepository
Expand All @@ -28,8 +20,8 @@ class EvaluationServiceImpl(
private val ruleParser: RuleParser,
private val boundedEvaluator: BoundedEvaluator,
private val inputHasher: InputHasher,
private val inputMapper: InputMapper,
private val logWriter: DecisionLogWriter,
private val properties: DecisionApiProperties,
) : EvaluationService {
@Transactional
override fun evaluate(
Expand All @@ -38,7 +30,7 @@ class EvaluationServiceImpl(
): EvaluationOutcome {
val version = loadActiveVersion(ruleKey)
val rule = ruleParser.parse(version.dsl)
val input = toEvaluationInput(attributes)
val input = inputMapper.toEvaluationInput(attributes)
val hash = inputHasher.hash(input)
val result = boundedEvaluator.evaluate(rule, input)
val logId = logWriter.write(version.id, hash, result)
Expand All @@ -50,22 +42,4 @@ class EvaluationServiceImpl(
val versionId = rule.activeVersionId ?: throw RuleNotActiveException(ruleKey)
return versionRepository.findById(versionId).orElseThrow { RuleNotActiveException(ruleKey) }
}

private fun toEvaluationInput(attributes: Map<String, JsonNode>): EvaluationInput {
if (attributes.size > properties.maxInputAttributes) throw InputTooLargeException(properties.maxInputAttributes)
return EvaluationInput(attributes.mapValues { (_, node) -> toAttrValue(node) })
}

private fun toAttrValue(node: JsonNode): AttrValue =
when {
node.isTextual -> StringValue(boundedText(node.textValue()))
node.isBoolean -> BoolValue(node.booleanValue())
node.isNumber -> DecimalValue(node.decimalValue())
else -> throw InputNotMappableException()
}

private fun boundedText(value: String): String {
if (value.length > properties.maxInputValueChars) throw InputTooLargeException(properties.maxInputValueChars)
return value
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: BUSL-1.1
// SPDX-FileCopyrightText: 2026 FinCore Engine Authors

package com.fincore.decision.store.application

import com.fasterxml.jackson.databind.JsonNode
import com.fincore.decision.domain.AttrValue
import com.fincore.decision.domain.BoolValue
import com.fincore.decision.domain.DecimalValue
import com.fincore.decision.domain.EvaluationInput
import com.fincore.decision.domain.StringValue
import com.fincore.decision.store.config.DecisionApiProperties
import com.fincore.decision.store.exception.InputNotMappableException
import com.fincore.decision.store.exception.InputTooLargeException
import org.springframework.stereotype.Component

/**
* Maps a JSON input object to typed evaluation attributes, enforcing the attribute-count and per-value
* string-length caps before the input reaches the engine. Shared by the evaluate and replay paths so the
* caps are defined once.
*/
@Component
class InputMapper(
private val properties: DecisionApiProperties,
) {
fun toEvaluationInput(attributes: Map<String, JsonNode>): EvaluationInput {
if (attributes.size > properties.maxInputAttributes) throw InputTooLargeException(properties.maxInputAttributes)
return EvaluationInput(attributes.mapValues { (_, node) -> toAttrValue(node) })
}

private fun toAttrValue(node: JsonNode): AttrValue =
when {
node.isTextual -> StringValue(boundedText(node.textValue()))
node.isBoolean -> BoolValue(node.booleanValue())
node.isNumber -> DecimalValue(node.decimalValue())
else -> throw InputNotMappableException()
}

private fun boundedText(value: String): String {
if (value.length > properties.maxInputValueChars) throw InputTooLargeException(properties.maxInputValueChars)
return value
}
}
Loading
Loading