From 125c008abc9b1d11bf4a8a0551c85acb9cfe21c0 Mon Sep 17 00:00:00 2001 From: Yassine Rhouma Date: Fri, 24 Jul 2026 12:00:59 +0200 Subject: [PATCH 1/9] feat(api): add sync attest and verify endpoints --- docs/spec/openapi.yaml | 11 +- dva-api/api/build.gradle.kts | 2 + .../hu/bme/mit/ftsrg/dva/api/Application.kt | 33 +- .../mit/ftsrg/dva/api/db/PgRequestLogRepo.kt | 20 +- .../mit/ftsrg/dva/api/db/requestLogMapping.kt | 27 +- .../hu/bme/mit/ftsrg/dva/api/err/APIErr.kt | 6 + .../hu/bme/mit/ftsrg/dva/api/err/ErrType.kt | 3 +- .../mit/ftsrg/dva/api/err/MissingVLAErr.kt | 8 + .../mit/ftsrg/dva/api/err/UnimplementedErr.kt | 6 +- .../bme/mit/ftsrg/dva/api/err/errHandling.kt | 39 +- .../bme/mit/ftsrg/dva/api/route/aovRoutes.kt | 116 +++- .../bme/mit/ftsrg/dva/api/route/infoRoutes.kt | 4 +- .../mit/ftsrg/dva/api/upstream/UpstreamErr.kt | 21 + .../mit/ftsrg/dva/api/upstream/upstream.kt | 75 +++ .../hu/bme/mit/ftsrg/dva/api/util/hashing.kt | 19 + .../api/src/main/resources/application.yaml | 8 +- .../bme/mit/ftsrg/dva/api/ApplicationTest.kt | 2 +- .../mit/ftsrg/dva/api/route/AoVRoutesTest.kt | 556 ++++++++++++------ .../api/testutil/ApplicationTestHelpers.kt | 3 + .../mit/ftsrg/dva/api/testutil/httpmocking.kt | 15 + .../mit/ftsrg/dva/api/testutil/temporal.kt | 10 + dva-api/gradle/libs.versions.toml | 5 +- .../kotlin/hu/bme/mit/ftsrg/dva/dto/IDDTO.kt | 6 - .../dva/dto/aov/AttestationRequestDTO.kt | 14 - .../aov/AttestationVerificationRequestDTO.kt | 17 - .../bme/mit/ftsrg/dva/dto/api/attestation.kt | 39 ++ .../dva/dto/{ErrDTO.kt => api/errors.kt} | 4 +- .../bme/mit/ftsrg/dva/dto/api/verification.kt | 14 + .../ftsrg/dva/dto/processing/evaluation.kt | 24 + .../mit/ftsrg/dva/dto/vcmanager/issuance.kt | 22 + .../ftsrg/dva/dto/vcmanager/verification.kt | 7 + .../mit/ftsrg/dva/log/FakeReqestLogRepo.kt | 35 -- .../mit/ftsrg/dva/log/FakeRequestLogRepo.kt | 19 + .../hu/bme/mit/ftsrg/dva/log/RequestLog.kt | 36 +- .../{ReqestLogRepo.kt => RequestLogRepo.kt} | 4 +- 35 files changed, 866 insertions(+), 364 deletions(-) create mode 100644 dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/APIErr.kt create mode 100644 dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/MissingVLAErr.kt create mode 100644 dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/upstream/UpstreamErr.kt create mode 100644 dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/upstream/upstream.kt create mode 100644 dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/util/hashing.kt create mode 100644 dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/httpmocking.kt create mode 100644 dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/temporal.kt delete mode 100644 dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/IDDTO.kt delete mode 100644 dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationRequestDTO.kt delete mode 100644 dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationVerificationRequestDTO.kt create mode 100644 dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/attestation.kt rename dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/{ErrDTO.kt => api/errors.kt} (86%) create mode 100644 dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/verification.kt create mode 100644 dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/processing/evaluation.kt create mode 100644 dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/vcmanager/issuance.kt create mode 100644 dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/vcmanager/verification.kt delete mode 100644 dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/FakeReqestLogRepo.kt create mode 100644 dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/FakeRequestLogRepo.kt rename dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/{ReqestLogRepo.kt => RequestLogRepo.kt} (72%) diff --git a/docs/spec/openapi.yaml b/docs/spec/openapi.yaml index d66bd4c9..172c426c 100644 --- a/docs/spec/openapi.yaml +++ b/docs/spec/openapi.yaml @@ -486,7 +486,6 @@ paths: - engine: JQ implementation: >- { success: .actor.name | contains('Dupont') } - attesterID: attester-0000 data: actor: name: Jean Dupont @@ -784,7 +783,7 @@ components: enum: [SCHEMA, GREAT_EXPECTATIONS, JQ] VeracityRequest: type: object - required: [exchangeID, contract, data] + required: [exchangeID, contract, data, vlaID] properties: exchangeID: type: string @@ -800,6 +799,9 @@ components: $ref: https://raw.githubusercontent.com/bitol-io/open-data-contract-standard/refs/heads/main/schema/odcs-json-schema-latest.json data: $ref: '#/components/schemas/AnyValue' + vlaID: + type: string + example: 14bd2062-0ffc-4b83-830b-aaa9aa1a1ca3 example: exchangeID: xchg-0001 contract: @@ -866,14 +868,9 @@ components: result: success: true timestamp: '2025-03-16T03:25:00Z' - attesterID: attester-0000 AttestationRequest: allOf: - $ref: '#/components/schemas/VeracityRequest' - required: [attesterID] - properties: - attesterID: - type: string ProofRequest: allOf: - $ref: '#/components/schemas/VeracityRequest' diff --git a/dva-api/api/build.gradle.kts b/dva-api/api/build.gradle.kts index 89fd84f3..0c432775 100644 --- a/dva-api/api/build.gradle.kts +++ b/dva-api/api/build.gradle.kts @@ -23,7 +23,9 @@ dependencies { runtimeOnly(libs.logevents) testImplementation(libs.ktor.client.content.negotiation) + testImplementation(libs.ktor.client.mock) testImplementation(libs.ktor.server.test.host) + testImplementation(libs.mockk) } application { diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/Application.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/Application.kt index 576fceb8..e71c9912 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/Application.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/Application.kt @@ -1,9 +1,15 @@ package hu.bme.mit.ftsrg.dva.api -import hu.bme.mit.ftsrg.dva.api.db.* +import hu.bme.mit.ftsrg.dva.api.db.PgRequestLogRepo +import hu.bme.mit.ftsrg.dva.api.db.configureDatabases import hu.bme.mit.ftsrg.dva.api.err.addHandlers -import hu.bme.mit.ftsrg.dva.api.route.* -import hu.bme.mit.ftsrg.dva.log.ReqestLogRepo +import hu.bme.mit.ftsrg.dva.api.route.aovRoutes +import hu.bme.mit.ftsrg.dva.api.route.docRoutes +import hu.bme.mit.ftsrg.dva.api.route.infoRoutes +import hu.bme.mit.ftsrg.dva.api.upstream.Upstream +import hu.bme.mit.ftsrg.dva.api.upstream.UpstreamClient +import hu.bme.mit.ftsrg.dva.api.upstream.configureForUpstreams +import hu.bme.mit.ftsrg.dva.log.RequestLogRepo import io.ktor.client.* import io.ktor.client.engine.cio.CIO import io.ktor.http.* @@ -18,7 +24,8 @@ import kotlinx.serialization.json.Json import org.koin.dsl.module import org.koin.ktor.plugin.Koin import org.slf4j.event.Level -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation +import kotlin.time.Clock +import kotlin.time.ExperimentalTime import io.ktor.server.application.install as serverInstall import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation @@ -54,19 +61,15 @@ fun Application.installPlugins() { serverInstall(Resources) } +@OptIn(ExperimentalTime::class) fun Application.configureKoin() { + val upstreamURLs: Map = + Upstream.entries.associateWith { environment.config.property(it.configKey).getString() } val appModule = module { - single { - HttpClient(CIO) { - install(ClientContentNegotiation) { - json(Json { - explicitNulls = true - ignoreUnknownKeys = true - }) - } - } - } - single { PgRequestLogRepo() } + single { HttpClient(CIO) { configureForUpstreams() } } + single { PgRequestLogRepo() } + single { Clock.System } + single { UpstreamClient(http = get(), baseURLs = upstreamURLs) } } serverInstall(Koin) { modules(appModule) } diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/db/PgRequestLogRepo.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/db/PgRequestLogRepo.kt index 9d2e928c..bf517cbe 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/db/PgRequestLogRepo.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/db/PgRequestLogRepo.kt @@ -1,8 +1,7 @@ package hu.bme.mit.ftsrg.dva.api.db -import hu.bme.mit.ftsrg.dva.log.ReqestLogRepo import hu.bme.mit.ftsrg.dva.log.RequestLog -import hu.bme.mit.ftsrg.dva.log.RequestLogNew +import hu.bme.mit.ftsrg.dva.log.RequestLogRepo import kotlinx.datetime.TimeZone.Companion.UTC import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.json.Json @@ -12,7 +11,7 @@ import kotlin.uuid.Uuid import kotlin.uuid.toJavaUuid @OptIn(ExperimentalUuidApi::class, ExperimentalTime::class) -class PgRequestLogRepo : ReqestLogRepo { +class PgRequestLogRepo : RequestLogRepo { override suspend fun all(): List = suspendTransaction { RequestLogEntity.all().map { it.toModel() } } @@ -21,21 +20,18 @@ class PgRequestLogRepo : ReqestLogRepo { RequestLogEntity.findById(id.toJavaUuid())?.toModel() } - override suspend fun add(request: RequestLogNew): RequestLog? = suspendTransaction { + override suspend fun add(request: RequestLog): RequestLog? = suspendTransaction { RequestLogEntity.new { type = request.type.name - requestID = request.requestID.toString() - exchangeID = request.exchangeID - contractID = request.contractID + exchangeID = request.exchangeID.toString() + contractID = request.contractID.toString() vlaID = request.vlaID.toString() data = Json.encodeToString(request.data) - attesterID = request.attesterID - evaluationPassing = request.evaluationPassing ?: false + evaluationPassing = request.evaluationPassing evaluationResults = Json.encodeToString(request.evaluationResults) receivedDate = request.receivedDate.toLocalDateTime(UTC) - evaluationDate = request.evaluationDate?.toLocalDateTime(UTC) - vcIssuedDate = request.vcIssuedDate?.toLocalDateTime(UTC) - vcID = request.vcID + vcID = request.vcID.toString() + error = request.error?.let { Json.encodeToString(it) } }.toModel() } } \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/db/requestLogMapping.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/db/requestLogMapping.kt index a02ff8d3..3a5b1633 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/db/requestLogMapping.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/db/requestLogMapping.kt @@ -20,52 +20,43 @@ import kotlin.uuid.toKotlinUuid object RequestLogsTable : UUIDTable("request_logs") { val type = varchar("type", 255) - val requestID = varchar("request_id", 255) val exchangeID = varchar("exchange_id", 255) val contractID = varchar("contract_id", 255) val vlaID = varchar("vla_id", 255) val data = text("data") - val attesterID = varchar("attester_id", 255) - val evaluationPassing = bool("evaluation_passing").nullable() - val evaluationResults = text("evaluation_results").nullable() + val evaluationPassing = bool("evaluation_passing") + val evaluationResults = text("evaluation_results") val receivedDate = datetime("received_date") - val evaluationDate = datetime("evaluation_date").nullable() - val vcIssuedDate = datetime("vc_issued_date").nullable() val vcID = varchar("vc_id", 255).nullable() + val error = text("error").nullable() } class RequestLogEntity(id: EntityID) : UUIDEntity(id) { companion object : UUIDEntityClass(RequestLogsTable) var type by RequestLogsTable.type - var requestID by RequestLogsTable.requestID var exchangeID by RequestLogsTable.exchangeID var contractID by RequestLogsTable.contractID var vlaID by RequestLogsTable.vlaID var data by RequestLogsTable.data - var attesterID by RequestLogsTable.attesterID var evaluationPassing by RequestLogsTable.evaluationPassing var evaluationResults by RequestLogsTable.evaluationResults var receivedDate by RequestLogsTable.receivedDate - var evaluationDate by RequestLogsTable.evaluationDate - var vcIssuedDate by RequestLogsTable.vcIssuedDate var vcID by RequestLogsTable.vcID + var error by RequestLogsTable.error } @OptIn(ExperimentalTime::class) fun RequestLogEntity.toModel() = RequestLog( id = id.value.toKotlinUuid(), type = RequestType.valueOf(type), - requestID = Uuid.parse(requestID), - exchangeID = exchangeID, - contractID = contractID, + exchangeID = Uuid.parse(exchangeID), + contractID = Uuid.parse(contractID), vlaID = Uuid.parse(vlaID), data = Json.decodeFromString(data), - attesterID = attesterID, evaluationPassing = evaluationPassing, - evaluationResults = evaluationResults, + evaluationResults = Json.decodeFromString(evaluationResults), receivedDate = receivedDate.toInstant(UTC), - evaluationDate = evaluationDate?.toInstant(UTC), - vcIssuedDate = vcIssuedDate?.toInstant(UTC), - vcID = vcID, + vcID = vcID?.let { Uuid.parse(it) }, + error = error?.let { Json.decodeFromString(it) }, ) \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/APIErr.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/APIErr.kt new file mode 100644 index 00000000..2258d7d7 --- /dev/null +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/APIErr.kt @@ -0,0 +1,6 @@ +package hu.bme.mit.ftsrg.dva.api.err + +import io.ktor.http.* + +abstract class APIErr(val type: ErrType, val status: HttpStatusCode, message: String, cause: Throwable? = null) : + RuntimeException(message, cause) \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/ErrType.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/ErrType.kt index 7476a502..8c30aad4 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/ErrType.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/ErrType.kt @@ -1,11 +1,12 @@ package hu.bme.mit.ftsrg.dva.api.err -import hu.bme.mit.ftsrg.dva.dto.ErrDTO +import hu.bme.mit.ftsrg.dva.dto.api.ErrDTO import java.net.URI enum class ErrType(val uri: URI, val title: String) { ALREADY_EXISTS(URI("/errors/exists"), "Resource already exists"), NOT_FOUND(URI("/errors/not_found"), "Resource Not Found"), + BAD_GATEWAY(URI("/errors/bad_gateway"), "Upstream service error"), UNIMPLEMENTED(URI("/errors/unimplemented"), "Unimplemented feature"), UNKNOWN(URI("/errors/unknown"), "Unknown Error"), } diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/MissingVLAErr.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/MissingVLAErr.kt new file mode 100644 index 00000000..490194f0 --- /dev/null +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/MissingVLAErr.kt @@ -0,0 +1,8 @@ +package hu.bme.mit.ftsrg.dva.api.err + +import io.ktor.http.* +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +class MissingVLAErr(val vlaID: Uuid) : APIErr(ErrType.NOT_FOUND, HttpStatusCode.NotFound, "VLA $vlaID not found") \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/UnimplementedErr.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/UnimplementedErr.kt index 57fd855e..e83fcb21 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/UnimplementedErr.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/UnimplementedErr.kt @@ -1,5 +1,5 @@ package hu.bme.mit.ftsrg.dva.api.err -object UnimplementedErr : Exception("This feature has not been implemented yet") { - private fun readResolve(): Any = UnimplementedErr -} \ No newline at end of file +import io.ktor.http.* + +class UnimplementedErr : APIErr(ErrType.UNIMPLEMENTED, HttpStatusCode.NotImplemented, "This feature is not implemented") \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/errHandling.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/errHandling.kt index e6f0a671..f86b83a1 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/errHandling.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/errHandling.kt @@ -1,11 +1,11 @@ package hu.bme.mit.ftsrg.dva.api.err -import hu.bme.mit.ftsrg.dva.api.err.ErrType.* -import hu.bme.mit.ftsrg.dva.dto.ErrDTO +import hu.bme.mit.ftsrg.dva.api.err.ErrType.NOT_FOUND +import hu.bme.mit.ftsrg.dva.dto.api.ErrDTO +import hu.bme.mit.ftsrg.dva.log.RequestLogError import io.github.oshai.kotlinlogging.KotlinLogging import io.ktor.http.HttpStatusCode.Companion.InternalServerError import io.ktor.http.HttpStatusCode.Companion.NotFound -import io.ktor.http.HttpStatusCode.Companion.NotImplemented import io.ktor.server.application.* import io.ktor.server.plugins.statuspages.* import io.ktor.server.request.* @@ -15,7 +15,7 @@ private val logger = KotlinLogging.logger {} fun StatusPagesConfig.addHandlers() { exception(::handleException) - status(NotFound) { call, _ -> handleNotFound(call) } + status(NotFound) { call, _ -> handleUnrouted(call) } } suspend fun handleException(call: ApplicationCall, cause: Throwable) { @@ -25,20 +25,19 @@ suspend fun handleException(call: ApplicationCall, cause: Throwable) { payload = mapOf("stacktrace" to cause.stackTraceToString()) } - when (cause) { - is UnimplementedErr -> { - call.respond(message = call.toErrorDTO(UNIMPLEMENTED, cause), status = NotImplemented) - } - - else -> { - call.respond(message = call.toErrorDTO(UNKNOWN, cause), status = InternalServerError) - } - } + val err = cause as? APIErr + call.respond( + status = err?.status ?: InternalServerError, + message = call.toErrorDTO(err?.type ?: ErrType.UNKNOWN, err), + ) } -suspend fun handleNotFound(call: ApplicationCall) { +/** + * Handle a request to a path that is not routed in ktor. + */ +suspend fun handleUnrouted(call: ApplicationCall) { logger.atWarn { - message = "Handling not found error" + message = "Handling unrouted request error" payload = mapOf("path" to call.request.path()) } call.respond(message = call.toErrorDTO(NOT_FOUND), status = NotFound) @@ -47,10 +46,14 @@ suspend fun handleNotFound(call: ApplicationCall) { private fun ApplicationCall.toErrorDTO( type: ErrType, cause: Throwable? = null, - init: ErrDTO.() -> Unit = {} ): ErrDTO = errDTO(type) { instance = request.path() }.apply { - if (cause != null) detail = "${cause.message}\n\nStack Trace:\n${cause.stackTraceToString()}" - }.apply(init) \ No newline at end of file + if (cause != null) detail = cause.message + } + +fun Throwable.toRequestLogError(): RequestLogError { + val err = this as? APIErr + return RequestLogError(title = (err?.type ?: ErrType.UNKNOWN).title, detail = message) +} \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/aovRoutes.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/aovRoutes.kt index d20af56f..b627e297 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/aovRoutes.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/aovRoutes.kt @@ -1,26 +1,132 @@ +@file:OptIn(ExperimentalUuidApi::class) + package hu.bme.mit.ftsrg.dva.api.route +import hu.bme.mit.ftsrg.dva.api.err.MissingVLAErr +import hu.bme.mit.ftsrg.dva.api.err.toRequestLogError import hu.bme.mit.ftsrg.dva.api.resource.Attestations +import hu.bme.mit.ftsrg.dva.api.upstream.Endpoint +import hu.bme.mit.ftsrg.dva.api.upstream.Upstream +import hu.bme.mit.ftsrg.dva.api.upstream.UpstreamClient +import hu.bme.mit.ftsrg.dva.api.upstream.UpstreamErr +import hu.bme.mit.ftsrg.dva.api.util.hash +import hu.bme.mit.ftsrg.dva.dto.api.AttestationRequest +import hu.bme.mit.ftsrg.dva.dto.api.AttestationResponse +import hu.bme.mit.ftsrg.dva.dto.api.AttestationVerificationRequest +import hu.bme.mit.ftsrg.dva.dto.api.AttestationVerificationResponse +import hu.bme.mit.ftsrg.dva.dto.processing.EvaluateBatchRequest +import hu.bme.mit.ftsrg.dva.dto.processing.EvaluationResult +import hu.bme.mit.ftsrg.dva.dto.vcmanager.AoVIssueRequest +import hu.bme.mit.ftsrg.dva.dto.vcmanager.AoVIssueResponse +import hu.bme.mit.ftsrg.dva.dto.vcmanager.AoVVerificationRequest +import hu.bme.mit.ftsrg.dva.dto.vcmanager.AoVVerificationResponse +import hu.bme.mit.ftsrg.dva.log.RequestLog +import hu.bme.mit.ftsrg.dva.log.RequestLogRepo +import hu.bme.mit.ftsrg.dva.log.RequestType +import io.ktor.client.request.* import io.ktor.http.* +import io.ktor.http.HttpStatusCode.Companion.OK import io.ktor.server.application.* +import io.ktor.server.request.* import io.ktor.server.resources.post +import io.ktor.server.response.* import io.ktor.server.routing.* +import kotlinx.serialization.json.JsonObject +import org.koin.ktor.ext.inject +import kotlin.time.Clock import kotlin.time.ExperimentalTime +import kotlin.time.Instant import kotlin.uuid.ExperimentalUuidApi @OptIn(ExperimentalTime::class, ExperimentalUuidApi::class) fun Application.aovRoutes() { - // val reqsRepo by inject() + val reqsRepo by inject() + val upstreams by inject() + val clock by inject() routing { post { - // TODO: Needs reimplementation since removal of RMQ - call.response.status(HttpStatusCode.NotImplemented) + val request: AttestationRequest = call.receive() + val now: Instant = clock.now() + + var log = RequestLog( + type = RequestType.ATTESTATION_REQUEST, + exchangeID = request.exchangeID, + contractID = request.contractID, + vlaID = request.vlaID, + data = request.data, + evaluationPassing = false, + evaluationResults = emptyList(), + receivedDate = now, + vcID = null + ) + + try { + // TODO: More type safety than just JsonObject? + val vla: JsonObject = upstreams.call( + endpoint = Endpoint.vla(request.vlaID), + mapStatus = { if (it == HttpStatusCode.NotFound) MissingVLAErr(request.vlaID) else null } + ) + + val results: List = upstreams.call( + endpoint = Endpoint.EVALUATE_BATCH, + method = HttpMethod.Post, + ) { + setBody(EvaluateBatchRequest(vla = vla, data = request.data)) + } + if (results.isEmpty()) { + throw UpstreamErr.UnexpectedBody(Upstream.PROCESSING, "no evaluation results received") + } + val allSuccess: Boolean = results.all { it.success } + log = log.copy(evaluationPassing = allSuccess, evaluationResults = results) + + // TODO: Is it correct to only create VC when every check passes? + val vcIssueResult: AoVIssueResponse? = if (allSuccess) { + upstreams.call( + endpoint = Endpoint.AOV_ISSUE, + method = HttpMethod.Post, + ) { + setBody( + AoVIssueRequest( + // TODO: Determine what the subject should be + subject = hash(request.data), + contractId = request.contractID, + dataExchangeId = request.exchangeID, + evaluationResults = results, + ) + ) + } + } else null + log = log.copy(vcID = vcIssueResult?.vcID) + + call.respond( + OK, + AttestationResponse( + jws = vcIssueResult?.jws, + evaluationPassing = allSuccess, + evaluationResults = results, + ) + ) + } catch (e: Throwable) { + log = log.copy(error = e.toRequestLogError()) + throw e + } finally { + reqsRepo.add(log) + } } post { - // TODO: Needs reimplementation since removal of ACA-Py - call.response.status(HttpStatusCode.NotImplemented) + val request: AttestationVerificationRequest = call.receive() + val vcVerifyResult: AoVVerificationResponse = upstreams.call( + endpoint = Endpoint.AOV_VERIFY, + method = HttpMethod.Post + ) { + setBody(request) + } + + // TODO: Log request + + call.respond(status = OK, message = vcVerifyResult) } } } \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/infoRoutes.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/infoRoutes.kt index e0ddbeec..3950491b 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/infoRoutes.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/infoRoutes.kt @@ -1,7 +1,7 @@ package hu.bme.mit.ftsrg.dva.api.route import hu.bme.mit.ftsrg.dva.api.resource.Info -import hu.bme.mit.ftsrg.dva.log.ReqestLogRepo +import hu.bme.mit.ftsrg.dva.log.RequestLogRepo import io.ktor.server.application.* import io.ktor.server.resources.* import io.ktor.server.response.* @@ -9,7 +9,7 @@ import io.ktor.server.routing.* import org.koin.ktor.ext.inject fun Application.infoRoutes() { - val reqsRepo by inject() + val reqsRepo by inject() routing { get { call.respond(reqsRepo.all()) } diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/upstream/UpstreamErr.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/upstream/UpstreamErr.kt new file mode 100644 index 00000000..05ecb8b6 --- /dev/null +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/upstream/UpstreamErr.kt @@ -0,0 +1,21 @@ +package hu.bme.mit.ftsrg.dva.api.upstream + +import hu.bme.mit.ftsrg.dva.api.err.APIErr +import hu.bme.mit.ftsrg.dva.api.err.ErrType +import io.ktor.http.* + +sealed class UpstreamErr(val service: Upstream, message: String, cause: Throwable? = null) : + APIErr(ErrType.BAD_GATEWAY, HttpStatusCode.BadGateway, message, cause) { + + class Unreachable(service: Upstream, cause: Throwable) : + UpstreamErr(service, "$service unreachable: ${cause.message}", cause) + + class BadStatus(service: Upstream, val upstreamStatus: HttpStatusCode, val body: String, cause: Throwable) : + UpstreamErr(service, "$service returned $upstreamStatus", cause) + + class BadBody(service: Upstream, cause: Throwable) : + UpstreamErr(service, "$service returned an unreadable body: ${cause.message}", cause) + + class UnexpectedBody(service: Upstream, reason: String) : + UpstreamErr(service, "$service returned an unexpected body: $reason") +} diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/upstream/upstream.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/upstream/upstream.kt new file mode 100644 index 00000000..e955e170 --- /dev/null +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/upstream/upstream.kt @@ -0,0 +1,75 @@ +package hu.bme.mit.ftsrg.dva.api.upstream + +import hu.bme.mit.ftsrg.dva.api.upstream.Upstream.* +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.serialization.* +import io.ktor.serialization.kotlinx.json.* +import io.ktor.util.network.* +import kotlinx.io.IOException +import kotlinx.serialization.json.Json +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +enum class Upstream(val configKey: String) { + VLA_MANAGER("vlaManager.url"), + PROCESSING("processing.url"), + VC_MANAGER("vcManager.url"), +} + +data class Endpoint(val service: Upstream, val path: String) { + companion object { + @OptIn(ExperimentalUuidApi::class) + fun vla(id: Uuid) = Endpoint(VLA_MANAGER, "vla/$id") + val EVALUATE_BATCH = Endpoint(PROCESSING, "evaluate-batch") + val AOV_ISSUE = Endpoint(VC_MANAGER, "aov/issue") + val AOV_VERIFY = Endpoint(VC_MANAGER, "aov/verify") + } +} + +class UpstreamClient( + @PublishedApi internal val http: HttpClient, + @PublishedApi internal val baseURLs: Map, +) { + suspend inline fun call( + endpoint: Endpoint, + method: HttpMethod = HttpMethod.Get, + noinline mapStatus: (HttpStatusCode) -> Throwable? = { null }, + noinline configure: HttpRequestBuilder.() -> Unit = {}, + ): T = try { + http.request("${baseURLs.getValue(endpoint.service)}/${endpoint.path}") { + this.method = method + contentType(ContentType.Application.Json) + accept(ContentType.Application.Json) + configure() + }.body() + } catch (e: ResponseException) { + throw mapStatus(e.response.status) ?: UpstreamErr.BadStatus( + endpoint.service, + e.response.status, + e.response.bodyAsText(), + e + ) + } catch (e: UnresolvedAddressException) { + throw UpstreamErr.Unreachable(endpoint.service, e) + } catch (e: IOException) { + throw UpstreamErr.Unreachable(endpoint.service, e) + } catch (e: JsonConvertException) { + throw UpstreamErr.BadBody(endpoint.service, e) + } +} + +fun HttpClientConfig<*>.configureForUpstreams() { + expectSuccess = true + install(ContentNegotiation) { + json(Json { + explicitNulls = true + ignoreUnknownKeys = true + }) + } +} \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/util/hashing.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/util/hashing.kt new file mode 100644 index 00000000..42c9941a --- /dev/null +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/util/hashing.kt @@ -0,0 +1,19 @@ +package hu.bme.mit.ftsrg.dva.api.util + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.security.MessageDigest + +fun hash(data: JsonElement): String { + val preimage: ByteArray = data.canonical().toString().toByteArray(Charsets.UTF_8) + val hash: ByteArray = MessageDigest.getInstance("SHA-256").digest(preimage) + return hash.toHexString() +} + +fun JsonElement.canonical(): JsonElement = when (this) { + is JsonObject -> JsonObject(entries.sortedBy { it.key }.associate { it.key to it.value.canonical() }) + is JsonArray -> JsonArray(map { it.canonical() }) + is JsonPrimitive -> this +} \ No newline at end of file diff --git a/dva-api/api/src/main/resources/application.yaml b/dva-api/api/src/main/resources/application.yaml index db58d6f4..284754a0 100644 --- a/dva-api/api/src/main/resources/application.yaml +++ b/dva-api/api/src/main/resources/application.yaml @@ -14,4 +14,10 @@ postgres: password: "$DVA_POSTGRES_PASSWORD:postgres" processing: - url: "$DVA_PROCESSING_URL:http://localhost:5000" \ No newline at end of file + url: "$DVA_PROCESSING_URL:http://localhost:5000" + +vlaManager: + url: "$DVA_VLA_MANAGER_URL:http://localhost:8000" + +vcManager: + url: "$DVA_VC_MANAGER_URL:http://localhost:8000" \ No newline at end of file diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/ApplicationTest.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/ApplicationTest.kt index 96d5017d..e7e3a034 100644 --- a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/ApplicationTest.kt +++ b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/ApplicationTest.kt @@ -3,7 +3,7 @@ package hu.bme.mit.ftsrg.dva.api import hu.bme.mit.ftsrg.dva.api.err.ErrType import hu.bme.mit.ftsrg.dva.api.testutil.createTestClient import hu.bme.mit.ftsrg.dva.api.testutil.setupTestApplication -import hu.bme.mit.ftsrg.dva.dto.ErrDTO +import hu.bme.mit.ftsrg.dva.dto.api.ErrDTO import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.http.* diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt index b9340026..903ae023 100644 --- a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt +++ b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt @@ -1,204 +1,406 @@ +@file:OptIn(ExperimentalTime::class, ExperimentalUuidApi::class) + package hu.bme.mit.ftsrg.dva.api.route -import hu.bme.mit.ftsrg.dva.api.testutil.createTestClient -import hu.bme.mit.ftsrg.dva.api.testutil.setupTestApplication -import hu.bme.mit.ftsrg.dva.dto.aov.AttestationRequestDTO -import hu.bme.mit.ftsrg.dva.log.FakeReqestLogRepo -import hu.bme.mit.ftsrg.dva.log.ReqestLogRepo +import hu.bme.mit.ftsrg.dva.api.err.ErrType +import hu.bme.mit.ftsrg.dva.api.testutil.* +import hu.bme.mit.ftsrg.dva.api.upstream.Endpoint +import hu.bme.mit.ftsrg.dva.api.upstream.Upstream +import hu.bme.mit.ftsrg.dva.api.upstream.UpstreamClient +import hu.bme.mit.ftsrg.dva.api.upstream.configureForUpstreams +import hu.bme.mit.ftsrg.dva.api.util.hash +import hu.bme.mit.ftsrg.dva.dto.api.* +import hu.bme.mit.ftsrg.dva.dto.processing.EvaluateBatchRequest +import hu.bme.mit.ftsrg.dva.dto.processing.EvaluationResult +import hu.bme.mit.ftsrg.dva.dto.vcmanager.AoVIssueRequest +import hu.bme.mit.ftsrg.dva.dto.vcmanager.AoVIssueResponse +import hu.bme.mit.ftsrg.dva.dto.vcmanager.AoVVerificationRequest +import hu.bme.mit.ftsrg.dva.dto.vcmanager.AoVVerificationResponse +import hu.bme.mit.ftsrg.dva.log.RequestLog +import hu.bme.mit.ftsrg.dva.log.RequestLogRepo +import hu.bme.mit.ftsrg.dva.log.RequestType import io.ktor.client.* -import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.call.* +import io.ktor.client.engine.mock.* import io.ktor.client.request.* -import io.ktor.http.* -import io.ktor.serialization.kotlinx.json.* +import io.ktor.client.statement.* +import io.ktor.content.* +import io.ktor.http.HttpStatusCode.Companion.BadGateway +import io.ktor.http.HttpStatusCode.Companion.NotFound +import io.ktor.http.HttpStatusCode.Companion.OK import io.ktor.server.application.* import io.ktor.server.testing.* -import kotlinx.serialization.json.* -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.Disabled +import io.ktor.util.network.* +import io.mockk.* +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNull +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource import org.koin.dsl.module import org.koin.ktor.plugin.Koin -import java.util.* +import java.net.ConnectException +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +// --- Fixtures --------------------------------------------------------------- + +private val xchgUUID = Uuid.random() +private val contractUUID = Uuid.random() +private val vlaUUID = Uuid.random() + +private val emptyVLA = buildJsonObject {} + +private val passingData = buildJsonObject { putJsonObject("result") { put("success", true) } } + +private val attestationRequest = AttestationRequest( + exchangeID = xchgUUID, + contractID = contractUUID, + vlaID = vlaUUID, + data = passingData, +) + +private val passingEvalResult = EvaluationResult( + engine = "TEST_ENGINE", timestamp = FixedClock.now(), success = true +) + +private val failingEvalResult = EvaluationResult( + engine = "TEST_ENGINE", + timestamp = FixedClock.now(), + success = false, + error = "test engine failed due to foo bar baz" +) + +private val testJWS = "jws_placeholder" +private val successfullyIssuedAoV = AoVIssueResponse(jws = testJWS, vcID = Uuid.random()) + +private val successfullyVerifiedAoV = AoVVerificationResponse(verified = true) + +private val expectedLog = RequestLog( + type = RequestType.ATTESTATION_REQUEST, + exchangeID = xchgUUID, + contractID = contractUUID, + vlaID = vlaUUID, + data = passingData, + evaluationPassing = true, + evaluationResults = listOf(passingEvalResult), + receivedDate = FixedClock.now(), + vcID = successfullyIssuedAoV.vcID, +) + +private val verificationRequest = AttestationVerificationRequest(jws = testJWS) + +/** The upstreams the attestation route calls, in the order it calls them. */ +private val callOrder = listOf(Endpoint.vla(vlaUUID), Endpoint.EVALUATE_BATCH, Endpoint.AOV_ISSUE) + +// --- Arrangement ------------------------------------------------------------ + +private val HttpRequestData.endpointPath: String + get() = url.encodedPath.removePrefix("/") + +private suspend fun HttpClient.postAttestation(request: AttestationRequest = attestationRequest): HttpResponse = + post("/attestation") { setBody(request) } + +private suspend fun HttpClient.postVerification(request: AttestationVerificationRequest = verificationRequest): HttpResponse = + post("/attestation/verify") { setBody(request) } + +private fun upstreams( + vla: MockResponder = { jsonResponse(emptyVLA) }, + evaluate: MockResponder = { jsonResponse(listOf(passingEvalResult)) }, + issue: MockResponder = { jsonResponse(successfullyIssuedAoV) }, + verify: MockResponder = { jsonResponse(successfullyVerifiedAoV) }, +): MockResponder = { req -> + when (req.endpointPath) { + Endpoint.vla(vlaUUID).path -> vla(req) + Endpoint.EVALUATE_BATCH.path -> evaluate(req) + Endpoint.AOV_ISSUE.path -> issue(req) + Endpoint.AOV_VERIFY.path -> verify(req) + else -> respondError(NotFound) + } +} + +private fun failingAt(endpoint: Endpoint, error: () -> Throwable): MockResponder { + val croak: MockResponder = { throw error() } + return when (endpoint) { + Endpoint.EVALUATE_BATCH -> upstreams(evaluate = croak) + Endpoint.AOV_ISSUE -> upstreams(issue = croak) + else -> upstreams(vla = croak) + } +} class AoVRoutesTest { + private val reqsRepo: RequestLogRepo = mockk() + private val sentRequests = mutableListOf() + + @BeforeEach + fun setupReqsRepoMocking() { + coEvery { reqsRepo.add(any()) } answers { firstArg() } + } @Test - @Disabled - fun `should accept attestation request for processing`() = testApplication { - setupApplication() + fun `attestation returns 200 when everything checks out`() = testApplication { + // Arrange + setupApplication(upstreams()) val client = createTestClient() - val request = AttestationRequestDTO( - id = "request-test-0000", - exchangeID = "xchg-0000", - attesterID = "attester-0000", - contract = buildJsonObject { - put("id", "contract-0001") - put("dataProvider", "/catalog/participants/provider-test-id") - put("dataConsumer", "/catalog/participants/consumer-test-did") - put("serviceOffering", "/catalog/serviceofferings/serviceoffering-test-did") - - putJsonArray("purpose") { - addJsonObject { - put("purpose", "/catalog/serviceofferings") - put("piiCategory", buildJsonArray {}) - } - } + // Act + // Assert response payload + client.postAttestation().apply { + assertEquals(OK, status) + val body: AttestationResponse = body() + assertEquals(testJWS, body.jws) + assertTrue(body.evaluationPassing) + assertEquals(listOf(passingEvalResult), body.evaluationResults) + } - putJsonArray("negotiators") { - addJsonObject { - put("did", "/catalog/participants/provider-test-id") - } - addJsonObject { - put("did", "/catalog/participants/consumer-test-id") - } - } + // Assert upstream requests + assertUpstreamEndpoints(Endpoint.vla(vlaUUID), Endpoint.EVALUATE_BATCH, Endpoint.AOV_ISSUE) - put("status", "pending") - - putJsonArray("policy") { - addJsonObject { - put("uid", "/policy/policy-0-uid") - putJsonArray("permission") { - addJsonObject { - put("target", "/target/3f8d1b0e-8e2e-4b69-9b1f-089fe2f3e9d7") - put("action", "use") - } - } - } - } + // Assert upstream request bodies + // (VLA request is just a GET to /vla/{id} so there is nothing else to assert) + val evalRequestSent: EvaluateBatchRequest = sentBody(Endpoint.EVALUATE_BATCH) + assertEquals(EvaluateBatchRequest(vla = emptyVLA, data = passingData), evalRequestSent) + val vcRequestSent: AoVIssueRequest = sentBody(Endpoint.AOV_ISSUE) + assertEquals( + AoVIssueRequest( + subject = hash(passingData), + contractId = contractUUID, + dataExchangeId = xchgUUID, + evaluationResults = listOf(passingEvalResult) + ), vcRequestSent + ) - putJsonObject("vla") { - put("version", "1.0.0") - put("kind", "DataContract") - put("id", UUID.randomUUID().toString()) - put("status", "active") - put("name", "test") - put("dataProduct", "test") - put("apiVersion", "v3.0.1") - - putJsonArray("schema") { - addJsonObject { - put("schemaElement", "xapi_statement") - put("logicalType", "object") - putJsonArray("properties") { - addJsonObject { - put("schemaElement", "id") - put("logicalType", "string") - } - addJsonObject { - put("schemaElement", "actor") - put("logicalType", "object") - put("required", true) - } - addJsonObject { - put("schemaElement", "verb") - put("logicalType", "object") - put("required", true) - } - addJsonObject { - put("schemaElement", "object") - put("logicalType", "object") - put("required", true) - } - addJsonObject { - put("schemaElement", "result") - put("logicalType", "object") - } - addJsonObject { - put("schemaElement", "context") - put("logicalType", "object") - } - addJsonObject { - put("schemaElement", "timestamp") - put("logicalType", "string") - } - addJsonObject { - put("schemaElement", "stored") - put("logicalType", "string") - } - addJsonObject { - put("schemaElement", "version") - put("logicalType", "string") - } - } - putJsonArray("quality") { - addJsonObject { - put("dataQuality", "custom") - put("engine", "greatExpectations") - put( - "implementation", - """ - type: ExpectColumnValuesToBeBetween - kwargs: - column: timestamp - min_value: '2025-01-01T00:00:00Z' - max_value: '2026-01-01T00:00:00Z' - """.trimIndent() - ) - } - } - } - } - } - }, - data = Json.parseToJsonElement( - """ - { - "actor": { - "name": "Jean Dupont", - "mbox": "mailto:jeandupont@example.com" - }, - "verb": { - "id": "http://adlnet.gov/expapi/verbs/interacted", - "display": { - "en-US": "interacted" - } - }, - "object": { - "id": "https://navy.mil/netc/xapi/activities/simulations/b9e16535-4fc9-4c66-ac87-3ad7ce515f5c/events/0221144", - "definition": { - "name": { - "en-US": "Event in Simulator" - }, - "description": { - "en-US": "You're wearing all your PPE" - }, - "type": "http://adlnet.gov/expapi/activities/interaction" - } - }, - "context": { - "registration": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - "extensions": { - "https://w3id.org/xapi/cmi5/context/extensions/sessionid": "moodle-session-12345" - } - }, - "result": { - "success":true, - "extensions": { - "http://id.tincanapi.com/extension/severity": "info" - } - }, - "timestamp": "2024-03-16T30:25:00Z" + // Assert db logging + assertLogged(expectedLog) + } + + @Test + fun `attestation returns 200 with null JWS when evaluations do not pass`() = testApplication { + // Arrange + setupApplication(upstreams(evaluate = { jsonResponse(listOf(failingEvalResult)) })) + val client = createTestClient() + + // Act + // Assert response payload + client.postAttestation().apply { + assertEquals(OK, status) + val body: AttestationResponse = body() + assertNull(body.jws) + assertFalse(body.evaluationPassing) + assertEquals(listOf(failingEvalResult), body.evaluationResults) } - """ + + // Assert upstream requests + assertUpstreamEndpoints(Endpoint.vla(vlaUUID), Endpoint.EVALUATE_BATCH) + + // Assert db logging + assertLogged( + expectedLog.copy( + evaluationPassing = false, evaluationResults = listOf(failingEvalResult), vcID = null ) ) - client.post("/attestation") { - contentType(ContentType.Application.Json) - setBody(request) - }.apply { - Assertions.assertEquals(HttpStatusCode.Accepted, status) + } + + @ParameterizedTest(name = "{0}") + @MethodSource("attestationUpstreamTransportFailures") + fun `attestation upstream transport failure handled`(case: String, failing: Endpoint, error: () -> Throwable) = + testApplication { + // Arrange + setupApplication(failingAt(failing, error)) + val client = createTestClient() + + // Act + // Assert response payload + client.postAttestation().apply { + assertEquals(BadGateway, status) + val body: ErrDTO = body() + assertEquals(ErrType.BAD_GATEWAY.uri.toString(), body.type) + assertEquals(ErrType.BAD_GATEWAY.title, body.title) + } + + // Assert upstream requests + assertUpstreamEndpoints(*callOrder.take(callOrder.indexOf(failing) + 1).toTypedArray()) + + // Assert db logging + assertErrorLogged() + } + + @Test + fun `attestation handles when VLA is not found`() = testApplication { + // Arrange + setupApplication(upstreams(vla = { respondError(NotFound) })) + val client = createTestClient() + + // Act + // Assert response payload + client.postAttestation().apply { + assertEquals(NotFound, status) + val body: ErrDTO = body() + assertEquals(ErrType.NOT_FOUND.uri.toString(), body.type) + assertEquals(ErrType.NOT_FOUND.title, body.title) } + + // Assert upstream paths + assertUpstreamEndpoints(Endpoint.vla(vlaUUID)) + + // Assert db logging + assertErrorLogged() } - private fun ApplicationTestBuilder.setupApplication() = setupTestApplication { - val testModule = module { - single { FakeReqestLogRepo() } - single { HttpClient { install(ContentNegotiation) { json() } } } + @Test + fun `attestation handles when processing results are empty`() = testApplication { + // Arrange + setupApplication(upstreams(evaluate = { jsonResponse>(emptyList()) })) + val client = createTestClient() + + // Act + // Assert response payload + client.postAttestation().apply { + assertEquals(BadGateway, status) + val body: ErrDTO = body() + assertEquals(ErrType.BAD_GATEWAY.uri.toString(), body.type) + assertEquals(ErrType.BAD_GATEWAY.title, body.title) } - this.install(Koin) { modules(testModule) } - aovRoutes() + // Assert upstream paths + assertUpstreamEndpoints(Endpoint.vla(vlaUUID), Endpoint.EVALUATE_BATCH) + + // Assert db logging + assertErrorLogged() } -} + + @Test + fun `attestation verification returns 200 when everything checks out`() = testApplication { + // Arrange + setupApplication(upstreams()) + val client = createTestClient() + + // Act + // Assert response payload + client.postVerification().apply { + assertEquals(OK, status) + val body: AttestationVerificationResponse = body() + assertTrue(body.verified) + assertNull(body.reason) + } + + // Assert upstream requests + assertUpstreamEndpoints(Endpoint.AOV_VERIFY) + + // Assert upstream request bodies + val vcRequestSent: AoVVerificationRequest = sentBody(Endpoint.AOV_VERIFY) + assertEquals(AoVVerificationRequest(jws = testJWS), vcRequestSent) + + // TODO: assert logged + } + + @Test + fun `attestation verification handles VC manager unreachable`() = testApplication { + // Arrange + setupApplication(upstreams(verify = { throw ConnectException() })) + val client = createTestClient() + + // Act + // Assert response payload + client.postVerification().apply { + assertEquals(BadGateway, status) + val body: ErrDTO = body() + assertEquals(ErrType.BAD_GATEWAY.uri.toString(), body.type) + assertEquals(ErrType.BAD_GATEWAY.title, body.title) + } + + // Assert upstream requests + assertUpstreamEndpoints(Endpoint.AOV_VERIFY) + + // TODO: assert logged + } + + @Test + fun `attestation verification handles VC manager unresolvable`() = testApplication { + // Arrange + setupApplication(upstreams(verify = { throw UnresolvedAddressException() })) + val client = createTestClient() + + // Act + // Assert response payload + client.postVerification().apply { + assertEquals(BadGateway, status) + val body: ErrDTO = body() + assertEquals(ErrType.BAD_GATEWAY.uri.toString(), body.type) + assertEquals(ErrType.BAD_GATEWAY.title, body.title) + } + + // Assert upstream requests + assertUpstreamEndpoints(Endpoint.AOV_VERIFY) + + // TODO: assert logged + } + + // TODO: handle potential invalid results from VC manager + // TODO: handle potential other errors from processing + + private fun assertLogged(expected: RequestLog) { + assertEquals(expected, capturedLog().copy(id = expected.id)) + } + + private fun assertErrorLogged() { + assertNotNull(capturedLog().error) + } + + private fun capturedLog(): RequestLog { + val logged: CapturingSlot = slot() + coVerify(exactly = 1) { reqsRepo.add(capture(logged)) } + confirmVerified(reqsRepo) + return logged.captured + } + + private fun assertUpstreamEndpoints(vararg expected: Endpoint) = + assertEquals(expected.map { it.path }, sentRequests.map { it.endpointPath }) + + private fun requestTo(endpoint: Endpoint): HttpRequestData = + sentRequests.singleOrNull { it.endpointPath == endpoint.path } + ?: fail("expected exactly one request to ${endpoint.path}, but sent ${sentRequests.map { it.endpointPath }}") + + private inline fun sentBody(endpoint: Endpoint): T = + Json.decodeFromString((requestTo(endpoint).body as TextContent).text) + + private fun ApplicationTestBuilder.setupApplication(handle: suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData) { + setupTestApplication { + val testModule = module { + single { reqsRepo } + single { FixedClock } + single { + HttpClient( + MockEngine { req -> sentRequests += req; handle(req) }) { configureForUpstreams() } + } + single { + UpstreamClient( + http = get(), Upstream.entries.associateWith { "http://${it.name.lowercase()}" }) + } + } + this.install(Koin) { modules(testModule) } + + aovRoutes() + } + } + + companion object { + @JvmStatic + fun attestationUpstreamTransportFailures() = listOf( + Arguments.of("VLA manager unreachable", Endpoint.vla(vlaUUID), { ConnectException() }), + Arguments.of("VLA manager unresolvable", Endpoint.vla(vlaUUID), { UnresolvedAddressException() }), + Arguments.of("processing module unreachable", Endpoint.EVALUATE_BATCH, { ConnectException() }), + Arguments.of("processing module unresolvable", Endpoint.EVALUATE_BATCH, { UnresolvedAddressException() }), + Arguments.of("VC manager unreachable", Endpoint.AOV_ISSUE, { ConnectException() }), + Arguments.of("VC manager unresolvable", Endpoint.AOV_ISSUE, { UnresolvedAddressException() }), + ) + } +} \ No newline at end of file diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/ApplicationTestHelpers.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/ApplicationTestHelpers.kt index fdcbf609..81c3b0c1 100644 --- a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/ApplicationTestHelpers.kt +++ b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/ApplicationTestHelpers.kt @@ -2,6 +2,8 @@ package hu.bme.mit.ftsrg.dva.api.testutil import hu.bme.mit.ftsrg.dva.api.err.addHandlers import io.ktor.client.* +import io.ktor.client.plugins.* +import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* import io.ktor.server.plugins.calllogging.* @@ -20,6 +22,7 @@ fun ApplicationTestBuilder.setupTestApplication(block: Application.() -> Unit = } fun ApplicationTestBuilder.createTestClient(block: HttpClientConfig<*>.() -> Unit = {}): HttpClient = createClient { + defaultRequest { contentType(ContentType.Application.Json) } install(ClientContentNegotiation) { json() } block() } diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/httpmocking.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/httpmocking.kt new file mode 100644 index 00000000..36231221 --- /dev/null +++ b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/httpmocking.kt @@ -0,0 +1,15 @@ +package hu.bme.mit.ftsrg.dva.api.testutil + +import io.ktor.client.engine.mock.* +import io.ktor.client.request.* +import io.ktor.http.* +import kotlinx.serialization.json.Json + +typealias MockResponder = suspend MockRequestHandleScope.(HttpRequestData) -> HttpResponseData + +inline fun MockRequestHandleScope.jsonResponse(body: T) = + respond( + content = Json.encodeToString(body), + status = HttpStatusCode.OK, + headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) \ No newline at end of file diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/temporal.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/temporal.kt new file mode 100644 index 00000000..71981fec --- /dev/null +++ b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/temporal.kt @@ -0,0 +1,10 @@ +package hu.bme.mit.ftsrg.dva.api.testutil + +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@OptIn(ExperimentalTime::class) +object FixedClock : Clock { + override fun now(): Instant = Instant.parse("2025-01-01T12:00:00Z") +} \ No newline at end of file diff --git a/dva-api/gradle/libs.versions.toml b/dva-api/gradle/libs.versions.toml index 8fdd3958..0e85dee7 100644 --- a/dva-api/gradle/libs.versions.toml +++ b/dva-api/gradle/libs.versions.toml @@ -3,7 +3,6 @@ datetime = "0.6.2" dependency-license-report = "2.9" exposed = "1.0.0-beta-5" handlebars-java = "4.5.0" -json-kotlin-schema = "0.57" koin = "4.0.3" kotlin = "2.2.0" kotlin-gradle-plugin = "2.2.0" @@ -12,6 +11,7 @@ kotlinx-serialization-json = "1.9.0" kover = "0.9.1" ktor = "3.3.1" logevents = "0.5.6" +mockk = "1.14.11" postgresql = "42.7.3" shadow = "9.0.2" slf4j = "2.0.14" @@ -23,7 +23,6 @@ exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exp exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } exposed-kotlin-datetime = { module = "org.jetbrains.exposed:exposed-kotlin-datetime", version.ref = "exposed" } handlebars-java = { module = "com.github.jknack:handlebars", version.ref = "handlebars-java" } -json-kotlin-schema = { module = "net.pwall.json:json-kotlin-schema", version.ref = "json-kotlin-schema" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core" } koin-ktor = { module = "io.insert-koin:koin-ktor" } @@ -36,6 +35,7 @@ ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-server-call-logging = { module = "io.ktor:ktor-server-call-logging", version.ref = "ktor" } ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } @@ -48,6 +48,7 @@ ktor-server-status-pages = { module = "io.ktor:ktor-server-status-pages", versio ktor-server-swagger = { module = "io.ktor:ktor-server-swagger", version.ref = "ktor" } ktor-server-test-host = { module = "io.ktor:ktor-server-test-host", version.ref = "ktor" } logevents = { module = "org.logevents:logevents", version.ref = "logevents" } +mockk = { module = "io.mockk:mockk-jvm", version.ref = "mockk" } postgresql = { module = "org.postgresql:postgresql", version.ref = "postgresql" } shadow-gradle-plugin = { module = "com.gradleup.shadow:com.gradleup.shadow.gradle.plugin", version.ref = "shadow" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/IDDTO.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/IDDTO.kt deleted file mode 100644 index 8cbf17a8..00000000 --- a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/IDDTO.kt +++ /dev/null @@ -1,6 +0,0 @@ -package hu.bme.mit.ftsrg.dva.dto - -import kotlinx.serialization.Serializable - -@Serializable -data class IDDTO(val id: String) \ No newline at end of file diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationRequestDTO.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationRequestDTO.kt deleted file mode 100644 index 1dcc5757..00000000 --- a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationRequestDTO.kt +++ /dev/null @@ -1,14 +0,0 @@ -package hu.bme.mit.ftsrg.dva.dto.aov - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonObject - -@Serializable -data class AttestationRequestDTO( - val id: String? = null, - val exchangeID: String, - val contract: JsonObject, - val data: JsonElement, - val attesterID: String, -) \ No newline at end of file diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationVerificationRequestDTO.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationVerificationRequestDTO.kt deleted file mode 100644 index 24ea9ba2..00000000 --- a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/aov/AttestationVerificationRequestDTO.kt +++ /dev/null @@ -1,17 +0,0 @@ -@file:UseSerializers(URLSerializer::class) - -package hu.bme.mit.ftsrg.dva.dto.aov - -import hu.bme.mit.ftsrg.serialization.URLSerializer -import kotlinx.serialization.Serializable -import kotlinx.serialization.UseSerializers -import java.net.URL - -@Serializable -data class AttestationVerificationRequestDTO( - val id: String? = null, - val exchangeID: String, - val contractID: String, - val attesterAgentURL: URL, - val attesterAgentLabel: String -) diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/attestation.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/attestation.kt new file mode 100644 index 00000000..622263c2 --- /dev/null +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/attestation.kt @@ -0,0 +1,39 @@ +@file:UseSerializers(URLSerializer::class) + +package hu.bme.mit.ftsrg.dva.dto.api + +import hu.bme.mit.ftsrg.dva.dto.processing.EvaluationResult +import hu.bme.mit.ftsrg.serialization.URLSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.UseSerializers +import kotlinx.serialization.json.JsonElement +import java.net.URL +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class) +@Serializable +data class AttestationRequest( + val id: String? = null, + val exchangeID: Uuid, + val contractID: Uuid, + val vlaID: Uuid, + val data: JsonElement, +) + +@Serializable +data class AttestationResponse( + val jws: String? = null, + val evaluationPassing: Boolean, + val evaluationResults: List, +) + + +@Serializable +data class AttestationVerificationRequestDTO( + val id: String? = null, + val exchangeID: String, + val contractID: String, + val attesterAgentURL: URL, + val attesterAgentLabel: String +) \ No newline at end of file diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/ErrDTO.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/errors.kt similarity index 86% rename from dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/ErrDTO.kt rename to dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/errors.kt index df067fdc..a923c0fc 100644 --- a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/ErrDTO.kt +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/errors.kt @@ -1,4 +1,4 @@ -package hu.bme.mit.ftsrg.dva.dto +package hu.bme.mit.ftsrg.dva.dto.api import kotlinx.serialization.Serializable @@ -11,4 +11,4 @@ data class ErrDTO( var title: String? = null, var detail: String? = null, var instance: String? = null -) \ No newline at end of file +) diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/verification.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/verification.kt new file mode 100644 index 00000000..4cc1e08b --- /dev/null +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/verification.kt @@ -0,0 +1,14 @@ +package hu.bme.mit.ftsrg.dva.dto.api + +import kotlinx.serialization.Serializable + +@Serializable +data class AttestationVerificationRequest( + val jws: String, +) + +@Serializable +data class AttestationVerificationResponse( + val verified: Boolean, + val reason: String? = null, +) diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/processing/evaluation.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/processing/evaluation.kt new file mode 100644 index 00000000..185482d3 --- /dev/null +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/processing/evaluation.kt @@ -0,0 +1,24 @@ +package hu.bme.mit.ftsrg.dva.dto.processing + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlin.time.ExperimentalTime +import kotlin.time.Instant + +@Serializable +data class EvaluateBatchRequest( + val vla: JsonObject, + val data: JsonElement, +) + +@OptIn(ExperimentalTime::class) +@Serializable +data class EvaluationResult( + val engine: String, + val timestamp: Instant, + val success: Boolean, + val details: String? = null, + val error: String? = null, +) + diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/vcmanager/issuance.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/vcmanager/issuance.kt new file mode 100644 index 00000000..898e9ef0 --- /dev/null +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/vcmanager/issuance.kt @@ -0,0 +1,22 @@ +@file:OptIn(ExperimentalUuidApi::class) + +package hu.bme.mit.ftsrg.dva.dto.vcmanager + +import hu.bme.mit.ftsrg.dva.dto.processing.EvaluationResult +import kotlinx.serialization.Serializable +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@Serializable +data class AoVIssueRequest( + val subject: String, + val contractId: Uuid, + val dataExchangeId: Uuid, + val evaluationResults: List, +) + +@Serializable +data class AoVIssueResponse( + val jws: String, + val vcID: Uuid, +) diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/vcmanager/verification.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/vcmanager/verification.kt new file mode 100644 index 00000000..d6b5c7df --- /dev/null +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/vcmanager/verification.kt @@ -0,0 +1,7 @@ +package hu.bme.mit.ftsrg.dva.dto.vcmanager + +import hu.bme.mit.ftsrg.dva.dto.api.AttestationVerificationRequest +import hu.bme.mit.ftsrg.dva.dto.api.AttestationVerificationResponse + +typealias AoVVerificationRequest = AttestationVerificationRequest +typealias AoVVerificationResponse = AttestationVerificationResponse \ No newline at end of file diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/FakeReqestLogRepo.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/FakeReqestLogRepo.kt deleted file mode 100644 index cbb462a3..00000000 --- a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/FakeReqestLogRepo.kt +++ /dev/null @@ -1,35 +0,0 @@ -package hu.bme.mit.ftsrg.dva.log - -import kotlin.time.ExperimentalTime -import kotlin.uuid.ExperimentalUuidApi -import kotlin.uuid.Uuid - -@OptIn(ExperimentalUuidApi::class, ExperimentalTime::class) -class FakeReqestLogRepo : ReqestLogRepo { - private val requests = mutableMapOf() - - override suspend fun all(): List = requests.values.toList() - - override suspend fun byID(id: Uuid): RequestLog? = requests[id] - - override suspend fun add(request: RequestLogNew): RequestLog? { - val entity = RequestLog( - id = Uuid.random(), - type = request.type, - requestID = request.requestID, - exchangeID = request.exchangeID, - contractID = request.contractID, - vlaID = request.vlaID, - data = request.data, - attesterID = request.attesterID, - evaluationPassing = request.evaluationPassing, - evaluationResults = request.evaluationResults, - receivedDate = request.receivedDate, - evaluationDate = request.evaluationDate, - vcIssuedDate = request.vcIssuedDate, - vcID = request.vcID, - ) - requests.put(entity.id, entity) - return entity - } -} \ No newline at end of file diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/FakeRequestLogRepo.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/FakeRequestLogRepo.kt new file mode 100644 index 00000000..226268ea --- /dev/null +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/FakeRequestLogRepo.kt @@ -0,0 +1,19 @@ +package hu.bme.mit.ftsrg.dva.log + +import kotlin.time.ExperimentalTime +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +@OptIn(ExperimentalUuidApi::class, ExperimentalTime::class) +class FakeRequestLogRepo : RequestLogRepo { + private val requests = mutableMapOf() + + override suspend fun all(): List = requests.values.toList() + + override suspend fun byID(id: Uuid): RequestLog? = requests[id] + + override suspend fun add(request: RequestLog): RequestLog { + requests[request.id] = request + return request + } +} \ No newline at end of file diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/RequestLog.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/RequestLog.kt index 30dc8af5..c0a246b3 100644 --- a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/RequestLog.kt +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/RequestLog.kt @@ -3,6 +3,7 @@ package hu.bme.mit.ftsrg.dva.log +import hu.bme.mit.ftsrg.dva.dto.processing.EvaluationResult import hu.bme.mit.ftsrg.serialization.URLSerializer import hu.bme.mit.ftsrg.serialization.UuidSerializer import kotlinx.serialization.Serializable @@ -15,38 +16,21 @@ import kotlin.uuid.Uuid @Serializable data class RequestLog( - val id: Uuid, + val id: Uuid = Uuid.random(), val type: RequestType, - val requestID: Uuid, - val exchangeID: String, - val contractID: String, - val vlaID: Uuid?, + val exchangeID: Uuid, + val contractID: Uuid, + val vlaID: Uuid, val data: JsonElement, - val attesterID: String, - val evaluationPassing: Boolean? = null, - val evaluationResults: String? = null, + val evaluationPassing: Boolean, + val evaluationResults: List, val receivedDate: Instant, - val evaluationDate: Instant? = null, - val vcIssuedDate: Instant? = null, - val vcID: String? = null, + val vcID: Uuid? = null, + val error: RequestLogError? = null ) @Serializable enum class RequestType { ATTESTATION_REQUEST, PROOF_REQUEST } @Serializable -data class RequestLogNew( - val type: RequestType, - val requestID: Uuid, - val exchangeID: String, - val contractID: String, - val vlaID: Uuid, - val data: JsonElement, - val attesterID: String, - val evaluationPassing: Boolean? = null, - val evaluationResults: String? = null, - val receivedDate: Instant, - val evaluationDate: Instant? = null, - val vcIssuedDate: Instant? = null, - val vcID: String? = null, -) \ No newline at end of file +data class RequestLogError(val title: String, val detail: String? = null) \ No newline at end of file diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/ReqestLogRepo.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/RequestLogRepo.kt similarity index 72% rename from dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/ReqestLogRepo.kt rename to dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/RequestLogRepo.kt index fd38e19c..c1872d2b 100644 --- a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/ReqestLogRepo.kt +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/log/RequestLogRepo.kt @@ -4,8 +4,8 @@ import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @OptIn(ExperimentalUuidApi::class) -interface ReqestLogRepo { +interface RequestLogRepo { suspend fun all(): List suspend fun byID(id: Uuid): RequestLog? - suspend fun add(request: RequestLogNew): RequestLog? + suspend fun add(request: RequestLog): RequestLog? } \ No newline at end of file From 73109e4b6512069837f1061cac7e684795097e4b Mon Sep 17 00:00:00 2001 From: Yassine Rhouma Date: Fri, 24 Jul 2026 12:06:15 +0200 Subject: [PATCH 2/9] docs(api): update openapi spec for attest endpoints --- docs/spec/dva-api.yaml | 375 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 docs/spec/dva-api.yaml diff --git a/docs/spec/dva-api.yaml b/docs/spec/dva-api.yaml new file mode 100644 index 00000000..17ac9c7b --- /dev/null +++ b/docs/spec/dva-api.yaml @@ -0,0 +1,375 @@ +openapi: 3.1.0 +info: + title: DVA API + version: 0.6.0 + description: |- + HTTP gateway / orchestrator for the attestation flow of the Data Veracity + Assurance (DVA) building block. + + Responsibilities of this service (and ONLY this service): + + 1. `POST /attestation` — attestation orchestration: + - Resolves the Veracity Level Agreement (VLA) via + `GET {vlaManagerURL}/vla/{id}` for the supplied `vlaId`. + - Calls `POST {processingURL}/evaluate-batch` with the contract data. + - If every quality evaluation passes, requests credential issuance via + `POST {vcManagerURL}/aov/issue` and returns the resulting Ed25519 JWS + in the response. + 2. `POST /attestation/verify` — verifies a JWS by proxying the request + body unchanged to `POST {vcManagerURL}/aov/verify`. + 3. `GET /info/requests` — audit rows (`RequestLog`). + 4. `GET /info/presentations` — audit rows (`VerifRequestLog`). + 5. `GET /info/credentials` — opaque passthrough of + `GET {acaPyAgentURL}/credentials`. + + The AoV JWS is returned in the `POST /attestation` response. If the veracity + checks fail, `evaluationPassing` is `false` and `jws` is `null`; a `200 OK` + is still returned. Invalid requests (e.g. malformed `vlaId`) yield + `400 BAD REQUEST`. +servers: + - url: http://localhost:9091 + description: Provider + - url: http://localhost:9092 + description: Consumer +tags: + - name: AoV + description: Endpoints related to attestations of veracity (AoVs) + - name: Info + description: Audit info endpoints backed by local `RequestLog` / `VerifRequestLog` tables or proxied agent calls +paths: + /attestation: + post: + tags: [AoV] + summary: Request an Attestation of Veracity (AoV) + description: |- + Attestation orchestration. The `contract` carries a reference to a VLA + via the top-level `vlaId` string UUID; this gateway resolves the VLA via + the VLA MANAGER API. `vlaId` is required. + operationId: requestAov + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AttestationRequest' + examples: + VlaIdReference: + summary: VLA reference by vlaId + value: + exchangeID: xchg-0001 + contract: + id: contract-0001 + dataProvider: did:web:provider.example.com:123 + dataConsumer: did:web:consumer.example.com:456 + serviceOffering: /catalog/serviceofferings/serviceoffering-test-did + purpose: [] + negotiators: + - did: did:web:provider.example.com:123 + - did: did:web:consumer.example.com:456 + status: PENDING + policy: + - uid: /policy/policy-0-uid + permission: + - type: permission + uid: /target/3f8d1b0e-8e2e-4b69-9b1f-089fe2f3e9d7 + action: use + vlaId: ddf4a56a-228b-461c-9448-d0e16135e315 + attesterID: attester-0000 + data: + actor: + name: Jean Dupont + mbox: mailto:jeandupont@example.com + verb: + id: http://adlnet.gov/expapi/verbs/interacted + object: + id: https://navy.mil/netc/xapi/activities/simulations/b9e16535-4fc9-4c66-ac87-3ad7ce515f5c/events/0221144 + definition: + name: + en-US: Event in Simulator + description: + en-US: You're wearing all your PPE + type: http://adlnet.gov/expapi/activities/interaction + result: + success: true + timestamp: '2025-03-16T03:25:00Z' + responses: + '200': + description: >- + Response — the AoV JWS (if all checks pass) or null JWS + (if checks fail). + content: + application/json: + schema: + $ref: '#/components/schemas/AoVResponse' + '400': + description: Malformed request (e.g. invalid `vlaId`). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: VLA not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: Request body was syntactically valid JSON but semantically malformed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '502': + description: >- + Downstream service (VLA MANAGER API, the processing service, or DVA VC + MANAGER) unreachable or returned an error. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /attestation/verify: + post: + tags: [AoV] + summary: Verify an attestation JWS + description: |- + Verifies a JWS by proxying the request body unchanged to + `POST {vcManagerURL}/aov/verify`. The downstream verifier performs the + Ed25519 signature check. + operationId: requestAovVerification + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AttestationVerifyRequest' + examples: + VerifyJws: + summary: Verify a compact JWS + value: + jws: >- + eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa29iQ2c4Y2tUZU1Hekg4RWZYYnVKU2RRNng3UXZnZGVMNkZqQjdDeng1V1VZV0FteSJ9..pQYZ8ViPzZbnY3RJZUE3Gp_b2GXG3oFnu1Px5r2to-sZGNDv5Cj8Qp5sJvbE_3gwec6GjNmNJZpK7ve1r7UtCw + responses: + '200': + description: Verification result. + content: + application/json: + schema: + $ref: '#/components/schemas/AttestationVerifyResponse' + '400': + description: Malformed verification request. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '422': + description: Request body was syntactically valid JSON but semantically malformed. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '502': + description: DVA VC MANAGER unreachable. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /info/requests: + get: + tags: [Info] + summary: List attestation request audit rows + description: >- + Returns `RequestLog` audit rows persisted by this gateway. The row + shape is dynamic (driven by the local audit schema) and intentionally + opaque at the API boundary. + operationId: getRequests + responses: + '200': + description: List of `RequestLog` audit rows. + content: + application/json: + schema: + type: array + description: RequestLog audit rows; opaque object shape defined by the local audit table. + items: + type: object + description: One RequestLog row. Object shape is dynamic and intentionally opaque. + additionalProperties: true + /info/presentations: + get: + tags: [Info] + summary: List verification request audit rows + description: >- + Returns `VerifRequestLog` audit rows persisted by this gateway. The row + shape is dynamic and intentionally opaque at the API boundary. + operationId: getPresentations + responses: + '200': + description: List of `VerifRequestLog` audit rows. + content: + application/json: + schema: + type: array + description: VerifRequestLog audit rows; opaque object shape defined by the local audit table. + items: + type: object + description: One VerifRequestLog row. Object shape is dynamic and intentionally opaque. + additionalProperties: true + /info/credentials: + get: + tags: [Info] + summary: List credentials from the ACA-Py agent + description: >- + Opaque passthrough of `GET {acaPyAgentURL}/credentials`. No body + transformation is performed; the agent's response is returned as-is. + operationId: getCredentials + responses: + '200': + description: Opaque credentials payload from the ACA-Py agent. + content: + application/json: + schema: + type: object + description: ACA-Py `/credentials` response, passed through unchanged. + additionalProperties: true + '502': + description: ACA-Py agent unreachable. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + VeracityRequest: + description: Base shape shared by attestation / verification requests. + type: object + required: [exchangeID, contract, data] + properties: + exchangeID: + type: string + example: xchg-0001 + contract: + type: object + description: >- + Data contract. `id` plus a top-level `vlaId` (reference resolved by + the gateway) must be supplied. + required: [id] + properties: + id: + type: string + example: contract-0001 + dataProvider: + type: string + description: >- + Optional. DID identifying the data provider; relevant only for + bilateral data exchanges. + example: did:web:provider.example.com:123 + data: + description: The veracity data payload unit (any JSON value). + AttestationRequest: + allOf: + - $ref: '#/components/schemas/VeracityRequest' + - type: object + required: [attesterID, vlaId] + properties: + attesterID: + type: string + example: attester-0000 + vlaId: + type: string + format: uuid + description: >- + Reference to a VLA owned by the VLA MANAGER. The gateway resolves + it via `GET {vlaManagerURL}/vla/{id}`. + example: ddf4a56a-228b-461c-9448-d0e16135e315 + QualityEngine: + description: Identifier of the evaluation engine invoked by the processing service. + type: string + enum: [SCHEMA, GREAT_EXPECTATIONS, JQ] + example: SCHEMA + EvaluationResult: + description: Result of one quality-engine evaluation run against `data`. + type: object + properties: + engine: + description: Engine that evaluated `data`. + $ref: '#/components/schemas/QualityEngine' + timestamp: + type: string + format: date-time + description: ISO-8601 timestamp at which the evaluation ran. + example: '2025-03-16T03:25:00Z' + success: + type: boolean + description: Whether the evaluated check passed. + example: true + details: + type: string + nullable: true + description: Human-readable success detail (absent or null on failure). + error: + type: string + nullable: true + description: Human-readable failure detail (absent or null on success). + required: [timestamp, success] + AoVResponse: + description: Attestation response returned by this gateway. + type: object + properties: + jws: + type: string + nullable: true + description: Compact Ed25519 JWS encoding the attestation, or null when checks failed. + evaluationPassing: + type: boolean + description: Whether every engine evaluation passed. + example: true + evaluationResults: + type: array + description: Per-engine evaluation results. + items: + $ref: '#/components/schemas/EvaluationResult' + required: [evaluationPassing, evaluationResults] + AttestationVerifyRequest: + description: JWS verification request proxied to DVA VC MANAGER's `/aov/verify`. + type: object + required: [jws] + properties: + jws: + type: string + description: Compact Ed25519 JWS to verify. + AttestationVerifyResponse: + description: Verification result returned from DVA VC MANAGER's `/aov/verify`. + type: object + required: [verified] + properties: + verified: + type: boolean + description: Whether the JWS signature was valid. + example: true + reason: + type: string + nullable: true + description: Failure reason when `verified` is false; null otherwise. + example: signature did not validate + Error: + description: RFC 7807 (application/problem+json) error object. + type: object + properties: + type: + type: string + description: A URI reference identifying the problem type. + example: about:blank + title: + type: string + description: Short human-readable summary of the problem type. + example: Bad Request + detail: + type: string + nullable: true + description: Human-readable explanation specific to this occurrence. + example: vlaId must be a valid UUID + instance: + type: string + nullable: true + description: URI reference identifying the specific occurrence of the problem. + required: [type, title] \ No newline at end of file From 1668477ff5ebe220530efaf9d0208f0535912a65 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Wed, 9 Sep 2026 13:10:45 +0200 Subject: [PATCH 3/9] docs(api): align OpenAPI specs with code and share common schemata --- docs/spec/components.yaml | 88 +++++ docs/spec/dva-api.yaml | 658 +++++++++++++++++++-------------- docs/spec/dva-vc-manager.yaml | 40 +- docs/spec/openapi.yaml | 24 +- docs/spec/vla-manager-api.yaml | 3 +- 5 files changed, 480 insertions(+), 333 deletions(-) create mode 100644 docs/spec/components.yaml diff --git a/docs/spec/components.yaml b/docs/spec/components.yaml new file mode 100644 index 00000000..d60181de --- /dev/null +++ b/docs/spec/components.yaml @@ -0,0 +1,88 @@ +--- +# Schemas shared between more than one DVA component. Referenced from +# the per-service specs as, eg, +# `$ref: './components.yaml#/schemas/EvaluationResult'`. +# +# This file is not an OpenAPI document of its own; it is only a $ref +# target, hence the bare top-level `schemas` key. +schemas: + QualityEngine: + description: >- + Identifier of a veracity requirement evaluation engine invoked by + the processing service. + type: string + enum: [SCHEMA, GREAT_EXPECTATIONS, JQ] + examples: [SCHEMA] + + EvaluationResult: + description: >- + The result of a single veracity requirement’s evaluation, as + produced by the processing service. Relayed verbatim by the DVA + API and embedded in the AoV issued by the VC Manager. + type: object + required: [engine, timestamp, success] + properties: + engine: + description: The quality engine used to obtain this result. + $ref: '#/schemas/QualityEngine' + timestamp: + description: ISO-8601 timestamp at which the evaluation ran. + type: string + format: date-time + examples: ['2025-03-16T03:25:00Z'] + success: + description: Whether the evaluated check passed. + type: boolean + examples: [true] + details: + description: >- + Human-readable success detail; absent on failure. + type: string + error: + description: >- + Human-readable failure detail; absent on success. + type: string + + VerifiableCredential: + description: >- + A W3C Verifiable Credential in its JSON-LD form. + type: object + required: + - '@context' + - id + - type + - issuer + - validFrom + - credentialSubject + properties: + '@context': + type: array + items: + type: string + examples: + - [https://w3.org] + id: + type: string + format: uri + type: + type: array + items: + type: string + issuer: + type: object + required: [id] + properties: + id: + type: string + format: uri + validFrom: + type: string + format: date-time + credentialSubject: + type: object + required: [id] + properties: + id: + type: string + format: uri + additionalProperties: true diff --git a/docs/spec/dva-api.yaml b/docs/spec/dva-api.yaml index 17ac9c7b..8bcb4214 100644 --- a/docs/spec/dva-api.yaml +++ b/docs/spec/dva-api.yaml @@ -1,50 +1,71 @@ -openapi: 3.1.0 +--- +openapi: 3.1.1 + + info: - title: DVA API - version: 0.6.0 - description: |- - HTTP gateway / orchestrator for the attestation flow of the Data Veracity - Assurance (DVA) building block. - - Responsibilities of this service (and ONLY this service): - - 1. `POST /attestation` — attestation orchestration: - - Resolves the Veracity Level Agreement (VLA) via - `GET {vlaManagerURL}/vla/{id}` for the supplied `vlaId`. - - Calls `POST {processingURL}/evaluate-batch` with the contract data. - - If every quality evaluation passes, requests credential issuance via - `POST {vcManagerURL}/aov/issue` and returns the resulting Ed25519 JWS - in the response. - 2. `POST /attestation/verify` — verifies a JWS by proxying the request - body unchanged to `POST {vcManagerURL}/aov/verify`. - 3. `GET /info/requests` — audit rows (`RequestLog`). - 4. `GET /info/presentations` — audit rows (`VerifRequestLog`). - 5. `GET /info/credentials` — opaque passthrough of - `GET {acaPyAgentURL}/credentials`. - - The AoV JWS is returned in the `POST /attestation` response. If the veracity - checks fail, `evaluationPassing` is `false` and `jws` is `null`; a `200 OK` - is still returned. Invalid requests (e.g. malformed `vlaId`) yield - `400 BAD REQUEST`. + title: Data Veracity Assurance Gateway + version: 0.1.0 + description: >- + This is the API specification of the + [data veracity assurance building block (DVA)](https://github.com/Prometheus-X-association/data-veracity). + + + Operations tagged `Future` are part of the intended contract but are + not served by the current implementation yet; see the note on each. + contact: + email: bpeter@edu.bme.hu + license: + name: Apache-2.0 + identifier: Apache-2.0 + + +# The service performs no authentication or authorisation of its own; an +# empty root-level requirement says so explicitly. +security: [] + + servers: - url: http://localhost:9091 - description: Provider + description: Test environment – Provider side - url: http://localhost:9092 - description: Consumer + description: Test environment – Consumer side + - url: http://localhost:9090 + description: Local development server on default port + - url: '{server}' + description: Custom + variables: + server: + default: http://localhost:9090 + description: Custom server URL + + tags: - name: AoV - description: Endpoints related to attestations of veracity (AoVs) + description: Attestation generation and verification - name: Info - description: Audit info endpoints backed by local `RequestLog` / `VerifRequestLog` tables or proxied agent calls + description: Retrieve information about the running instance + - name: Future + description: >- + Endpoints that are not available yet but will be implemented in + the future + + paths: /attestation: post: tags: [AoV] summary: Request an Attestation of Veracity (AoV) - description: |- - Attestation orchestration. The `contract` carries a reference to a VLA - via the top-level `vlaId` string UUID; this gateway resolves the VLA via - the VLA MANAGER API. `vlaId` is required. + description: >- + Create a new **Attestation of Veracity** for a given data + exchange. The associated VLA is obtained by ID from the VLA + Manager and its veracity requirements are evaluated by the + processing service. Only if *every* requirement passes is an + AoV verifiable credential issued by the VC Manager and returned + by this endpoint as a compact JWS. + + + The request is handled synchronously, and both successful and + failed attempts are logged; see `GET /info/requests`. operationId: requestAov requestBody: required: true @@ -53,323 +74,412 @@ paths: schema: $ref: '#/components/schemas/AttestationRequest' examples: - VlaIdReference: - summary: VLA reference by vlaId + simple: + summary: An exchange carrying a small JSON payload value: - exchangeID: xchg-0001 - contract: - id: contract-0001 - dataProvider: did:web:provider.example.com:123 - dataConsumer: did:web:consumer.example.com:456 - serviceOffering: /catalog/serviceofferings/serviceoffering-test-did - purpose: [] - negotiators: - - did: did:web:provider.example.com:123 - - did: did:web:consumer.example.com:456 - status: PENDING - policy: - - uid: /policy/policy-0-uid - permission: - - type: permission - uid: /target/3f8d1b0e-8e2e-4b69-9b1f-089fe2f3e9d7 - action: use - vlaId: ddf4a56a-228b-461c-9448-d0e16135e315 - attesterID: attester-0000 + exchangeID: 5287a608-36c1-40f1-8430-5eaad60c5eca + contractID: a37532aa-5e41-4a27-a6c8-a7b4089779a8 + vlaID: 6c92b868-49d2-4bcc-a5e7-bacb0f5b858a data: - actor: - name: Jean Dupont - mbox: mailto:jeandupont@example.com - verb: - id: http://adlnet.gov/expapi/verbs/interacted - object: - id: https://navy.mil/netc/xapi/activities/simulations/b9e16535-4fc9-4c66-ac87-3ad7ce515f5c/events/0221144 - definition: - name: - en-US: Event in Simulator - description: - en-US: You're wearing all your PPE - type: http://adlnet.gov/expapi/activities/interaction - result: - success: true - timestamp: '2025-03-16T03:25:00Z' + foo: bar + baz: [qux, quux] responses: '200': - description: >- - Response — the AoV JWS (if all checks pass) or null JWS - (if checks fail). - content: - application/json: - schema: - $ref: '#/components/schemas/AoVResponse' - '400': - description: Malformed request (e.g. invalid `vlaId`). - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/AoVGenerated' '404': - description: VLA not found. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/VLANotFound' + '415': + $ref: '#/components/responses/UnsupportedMediaType' '422': - description: Request body was syntactically valid JSON but semantically malformed. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/MalformedRequest' + '500': + $ref: '#/components/responses/InternalError' '502': - description: >- - Downstream service (VLA MANAGER API, the processing service, or DVA VC - MANAGER) unreachable or returned an error. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/UpstreamError' + + /attestation/verify: post: tags: [AoV] - summary: Verify an attestation JWS - description: |- - Verifies a JWS by proxying the request body unchanged to - `POST {vcManagerURL}/aov/verify`. The downstream verifier performs the - Ed25519 signature check. - operationId: requestAovVerification + summary: Verify an Attestation of Veracity + description: >- + Verify an **Attestation of Veracity**. The AoV is forwarded to + the VC Manager, which checks the JWS both cryptographically and + content-wise; this endpoint relays that verdict. + + + A `200 OK` only means that the verification itself ran; consult + `verified` in the body for the outcome. + operationId: verifyAov requestBody: required: true content: application/json: schema: - $ref: '#/components/schemas/AttestationVerifyRequest' + $ref: '#/components/schemas/AttestationVerificationRequest' examples: - VerifyJws: - summary: Verify a compact JWS + simple: + summary: A compact JWS as returned by `POST /attestation` value: jws: >- eyJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa29iQ2c4Y2tUZU1Hekg4RWZYYnVKU2RRNng3UXZnZGVMNkZqQjdDeng1V1VZV0FteSJ9..pQYZ8ViPzZbnY3RJZUE3Gp_b2GXG3oFnu1Px5r2to-sZGNDv5Cj8Qp5sJvbE_3gwec6GjNmNJZpK7ve1r7UtCw responses: '200': - description: Verification result. - content: - application/json: - schema: - $ref: '#/components/schemas/AttestationVerifyResponse' - '400': - description: Malformed verification request. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/AoVVerified' + '415': + $ref: '#/components/responses/UnsupportedMediaType' '422': - description: Request body was syntactically valid JSON but semantically malformed. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/MalformedRequest' + '500': + $ref: '#/components/responses/InternalError' '502': - description: DVA VC MANAGER unreachable. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/UpstreamError' + + /info/requests: get: tags: [Info] - summary: List attestation request audit rows + summary: List attestation requests received by this instance description: >- - Returns `RequestLog` audit rows persisted by this gateway. The row - shape is dynamic (driven by the local audit schema) and intentionally - opaque at the API boundary. + Returns a log entry for every request made to `/attestation`, + including the ones that failed. operationId: getRequests responses: '200': - description: List of `RequestLog` audit rows. - content: - application/json: - schema: - type: array - description: RequestLog audit rows; opaque object shape defined by the local audit table. - items: - type: object - description: One RequestLog row. Object shape is dynamic and intentionally opaque. - additionalProperties: true - /info/presentations: + $ref: '#/components/responses/RequestLogs' + '500': + $ref: '#/components/responses/InternalError' + + + /info/verifications: get: - tags: [Info] - summary: List verification request audit rows + tags: [Info, Future] + summary: List AoV verifications done by this instance description: >- - Returns `VerifRequestLog` audit rows persisted by this gateway. The row - shape is dynamic and intentionally opaque at the API boundary. - operationId: getPresentations + Returns a list of all AoVs that were verified by this instance + using `/attestation/verify`. + + + **Not implemented yet.** Verification requests are not logged + at present, so this path is unrouted and answers `404` like any + other unknown path. + operationId: getVerifications + x-implementation-status: planned responses: '200': - description: List of `VerifRequestLog` audit rows. - content: - application/json: - schema: - type: array - description: VerifRequestLog audit rows; opaque object shape defined by the local audit table. - items: - type: object - description: One VerifRequestLog row. Object shape is dynamic and intentionally opaque. - additionalProperties: true + $ref: '#/components/responses/VerificationLogs' + '500': + $ref: '#/components/responses/InternalError' + + /info/credentials: get: - tags: [Info] - summary: List credentials from the ACA-Py agent + tags: [Info, Future] + summary: List VCs held at this instance description: >- - Opaque passthrough of `GET {acaPyAgentURL}/credentials`. No body - transformation is performed; the agent's response is returned as-is. + Returns a list of all verifiable credentials held locally, + as reported by the ACA-Py agent. + + + **Not implemented yet.** This path is unrouted and answers + `404` like any other unknown path. operationId: getCredentials + x-implementation-status: planned responses: '200': - description: Opaque credentials payload from the ACA-Py agent. - content: - application/json: - schema: - type: object - description: ACA-Py `/credentials` response, passed through unchanged. - additionalProperties: true + $ref: '#/components/responses/Credentials' + '500': + $ref: '#/components/responses/InternalError' '502': - description: ACA-Py agent unreachable. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + $ref: '#/components/responses/UpstreamError' + + components: + responses: + AoVGenerated: + description: >- + The attestation request was processed + (the evaluation itself may still have failed, in which case no + JWS is returned). + content: + application/json: + schema: + $ref: '#/components/schemas/AttestationResponse' + AoVVerified: + description: >- + AoV successfully verified + (the verification process itself was successful but the AoV itself + might still be invalid). + content: + application/json: + schema: + $ref: '#/components/schemas/AttestationVerificationResponse' + VLANotFound: + description: The referenced VLA was not found. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + UnsupportedMediaType: + description: >- + Body was sent with a content type other than `application/json`. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + MalformedRequest: + description: >- + Body is not valid JSON, is missing required fields, or carries + fields the endpoint does not accept. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + InternalError: + description: >- + An unexpected error occurred while handling the request. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + UpstreamError: + description: >- + An upstream service (eg VLA Manager, processing, VC Manager) + was unreachable or returned an error or an unusable response. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + RequestLogs: + description: List of logged AoV generation requests. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RequestLog' + VerificationLogs: + description: List of logged AoV verification requests. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/VerificationLog' + Credentials: + description: The verifiable credentials held by this instance. + content: + application/json: + schema: + type: array + items: + $ref: './components.yaml#/schemas/VerifiableCredential' + + schemas: - VeracityRequest: - description: Base shape shared by attestation / verification requests. - type: object - required: [exchangeID, contract, data] - properties: - exchangeID: - type: string - example: xchg-0001 - contract: - type: object - description: >- - Data contract. `id` plus a top-level `vlaId` (reference resolved by - the gateway) must be supplied. - required: [id] - properties: - id: - type: string - example: contract-0001 - dataProvider: - type: string - description: >- - Optional. DID identifying the data provider; relevant only for - bilateral data exchanges. - example: did:web:provider.example.com:123 - data: - description: The veracity data payload unit (any JSON value). AttestationRequest: - allOf: - - $ref: '#/components/schemas/VeracityRequest' - - type: object - required: [attesterID, vlaId] - properties: - attesterID: - type: string - example: attester-0000 - vlaId: - type: string - format: uuid - description: >- - Reference to a VLA owned by the VLA MANAGER. The gateway resolves - it via `GET {vlaManagerURL}/vla/{id}`. - example: ddf4a56a-228b-461c-9448-d0e16135e315 - QualityEngine: - description: Identifier of the evaluation engine invoked by the processing service. - type: string - enum: [SCHEMA, GREAT_EXPECTATIONS, JQ] - example: SCHEMA - EvaluationResult: - description: Result of one quality-engine evaluation run against `data`. + description: >- + A request to generate an attestation for a given data exchange. type: object + required: [exchangeID, contractID, vlaID, data] + additionalProperties: false properties: - engine: - description: Engine that evaluated `data`. - $ref: '#/components/schemas/QualityEngine' - timestamp: + id: + description: >- + Client-supplied identifier. Accepted for backwards + compatibility but ignored; the log entry gets a + server-generated ID. type: string - format: date-time - description: ISO-8601 timestamp at which the evaluation ran. - example: '2025-03-16T03:25:00Z' - success: - type: boolean - description: Whether the evaluated check passed. - example: true - details: + exchangeID: + description: Identifies the data exchange. type: string - nullable: true - description: Human-readable success detail (absent or null on failure). - error: + format: uuid + examples: [1d7e4db0-145d-4f69-8774-9e1b36a4c813] + contractID: + description: Identifies the relevant contract. + type: string + format: uuid + examples: [317b2ccb-d0b0-4499-bcac-c347e8192512] + vlaID: + description: Identifies the relevant VLA with the requirements. type: string - nullable: true - description: Human-readable failure detail (absent or null on success). - required: [timestamp, success] - AoVResponse: - description: Attestation response returned by this gateway. + format: uuid + examples: [63e2b688-c538-4b10-9276-97ee138d0c5c] + data: + description: >- + The data being passed. Any JSON value – object, array or + scalar. + examples: + - {foo: bar, baz: [qux, quux]} + AttestationResponse: + description: AoV generation response containing an AoV as a JWS. type: object + required: [evaluationPassing, evaluationResults] properties: jws: + description: >- + An AoV VC as a compact JWS string. Absent when the + evaluation did not pass, as no credential is issued in that + case. type: string - nullable: true - description: Compact Ed25519 JWS encoding the attestation, or null when checks failed. evaluationPassing: + description: >- + Whether every veracity requirement’s evaluation passed. type: boolean - description: Whether every engine evaluation passed. - example: true + examples: [true] evaluationResults: + description: >- + Per-requirement evaluation results. Never empty; an empty + result set from the processing service is reported as a + `502` instead. type: array - description: Per-engine evaluation results. + minItems: 1 items: - $ref: '#/components/schemas/EvaluationResult' - required: [evaluationPassing, evaluationResults] - AttestationVerifyRequest: - description: JWS verification request proxied to DVA VC MANAGER's `/aov/verify`. + $ref: './components.yaml#/schemas/EvaluationResult' + AttestationVerificationRequest: + description: Requests the verification of an AoV. type: object required: [jws] + additionalProperties: false properties: jws: + description: The AoV as a compact JWS VC to verify. type: string - description: Compact Ed25519 JWS to verify. - AttestationVerifyResponse: - description: Verification result returned from DVA VC MANAGER's `/aov/verify`. + AttestationVerificationResponse: + description: The results of verifying an AoV. type: object required: [verified] properties: verified: + description: Whether the AoV was deemed valid in all aspects. type: boolean - description: Whether the JWS signature was valid. - example: true + examples: [true] reason: + description: >- + Failure reason when `verified` is false; absent otherwise. + type: string + examples: [invalid signature] + RequestLog: + description: >- + An object representing a logged attestation generation request. + type: object + required: + - id + - type + - exchangeID + - contractID + - vlaID + - data + - evaluationPassing + - evaluationResults + - receivedDate + properties: + id: + description: Server-generated unique identifier for this log entry. + type: string + format: uuid + type: + description: The kind of request that was logged. + $ref: '#/components/schemas/RequestType' + exchangeID: + description: >- + Identifies the data exchange for which this attestation + was attempted. + type: string + format: uuid + contractID: + description: Identifies the contract the exchange was made under. + type: string + format: uuid + vlaID: + description: >- + Identifies the VLA that was used to generate an attestation + against. + type: string + format: uuid + data: + description: >- + The piece of data that was attested to. Any JSON value – + object, array or scalar. + evaluationPassing: + description: Whether all VLA requirements passed. + type: boolean + evaluationResults: + description: >- + Per-requirement evaluation results. Empty if the request + failed before the processing service was reached. + type: array + items: + $ref: './components.yaml#/schemas/EvaluationResult' + receivedDate: + description: ISO-8601 timestamp at which the request was received. + type: string + format: date-time + examples: ['2025-03-16T03:25:00Z'] + vcID: + description: >- + Identifies the issued credential; absent if none was issued. + type: string + format: uuid + error: + description: >- + What went wrong while handling the request; absent if it + succeeded. + $ref: '#/components/schemas/RequestLogError' + VerificationLog: + description: >- + An object representing a logged attestation verification. + + + **Not specified yet**, pending the logging of verification + requests; see `GET /info/verifications`. + type: object + x-implementation-status: planned + RequestType: + description: Describes a type of veracity attestation request. + type: string + enum: [ATTESTATION_REQUEST, PROOF_REQUEST] + RequestLogError: + description: >- + The error that terminated a logged request. + type: object + required: [title] + properties: + title: + description: Short summary of the problem type. + type: string + examples: [Upstream service error] + detail: + description: >- + Human-readable explanation specific to this occurrence. type: string - nullable: true - description: Failure reason when `verified` is false; null otherwise. - example: signature did not validate Error: - description: RFC 7807 (application/problem+json) error object. + description: >- + RFC 9457-like error object. Note that it is served as + `application/json` rather than `application/problem+json`. type: object + required: [type, title] properties: type: + description: >- + A URI reference identifying the problem type. One of + `/errors/exists`, `/errors/not_found`, `/errors/bad_request`, + `/errors/unsupported_media_type`, `/errors/bad_gateway`, + `/errors/unimplemented` or `/errors/unknown`. type: string - description: A URI reference identifying the problem type. - example: about:blank + examples: ['/errors/not_found'] title: + description: Short summary of the problem type. type: string - description: Short human-readable summary of the problem type. - example: Bad Request + examples: [Upstream service error] detail: + description: >- + Human-readable explanation specific to this occurrence. type: string - nullable: true - description: Human-readable explanation specific to this occurrence. - example: vlaId must be a valid UUID + examples: [vlaId must be a valid UUID] instance: + description: >- + URI reference identifying the specific occurrence of the + problem. The request path, when known. type: string - nullable: true - description: URI reference identifying the specific occurrence of the problem. - required: [type, title] \ No newline at end of file + examples: ['/attestation'] diff --git a/docs/spec/dva-vc-manager.yaml b/docs/spec/dva-vc-manager.yaml index 78d8ff83..66bf675e 100644 --- a/docs/spec/dva-vc-manager.yaml +++ b/docs/spec/dva-vc-manager.yaml @@ -241,41 +241,11 @@ paths: components: schemas: QualityEngine: - type: string - enum: [SCHEMA, GREAT_EXPECTATIONS, JQ] - description: The evaluation engine that produced a veracity result. + $ref: './components.yaml#/schemas/QualityEngine' + # Embedded in `AovIssueRequest.evaluationResults`, exactly as the DVA API + # relays it from the processing service. EvaluationResult: - type: object - description: >- - Outcome of one VLA requirement evaluation, embedded in - `AovIssueRequest.evaluationResults`. - required: [timestamp, success] - additionalProperties: false - properties: - engine: - $ref: '#/components/schemas/QualityEngine' - timestamp: - type: string - format: date-time - description: RFC 3339 timestamp marking when the evaluation ran. - success: - type: boolean - description: Whether the evaluation passed. - details: - type: string - nullable: true - description: >- - Human-readable success explanation when `success` is true. - error: - type: string - nullable: true - description: >- - Human-readable failure explanation when `success` is false. - example: - engine: JQ - timestamp: '2026-01-31T17:48:10.904264Z' - success: true - details: Actor name is correct + $ref: './components.yaml#/schemas/EvaluationResult' AovIssueRequest: type: object description: Body of POST /aov/issue. @@ -439,7 +409,7 @@ components: format: date-time Error: type: object - description: RFC 7807-style problem object emitted on all error responses. + description: RFC 9457-style problem object emitted on all error responses. additionalProperties: false required: [type, title] properties: diff --git a/docs/spec/openapi.yaml b/docs/spec/openapi.yaml index 172c426c..5343a197 100644 --- a/docs/spec/openapi.yaml +++ b/docs/spec/openapi.yaml @@ -779,8 +779,7 @@ components: type: string enum: [SYNTAX, TIMELINESS, ACCURACY, COMPLETENESS, CONSISTENCY] QualityEngine: - type: string - enum: [SCHEMA, GREAT_EXPECTATIONS, JQ] + $ref: './components.yaml#/schemas/QualityEngine' VeracityRequest: type: object required: [exchangeID, contract, data, vlaID] @@ -945,26 +944,7 @@ components: action: read object: course_materials/lecturenotes/1 EvaluationResult: - type: object - required: [timestamp, success] - additionalProperties: false - properties: - engine: - type: QualityEngine - timestamp: - type: string - format: datetime - success: - type: bool - details: - type: string - error: - type: string - example: - engine: JQ - timestamp: '2026-01-31T17:48:10.904264Z' - success: true - details: Actor name is correct + $ref: './components.yaml#/schemas/EvaluationResult' Error: type: object required: [message] diff --git a/docs/spec/vla-manager-api.yaml b/docs/spec/vla-manager-api.yaml index cb538561..8f86777f 100644 --- a/docs/spec/vla-manager-api.yaml +++ b/docs/spec/vla-manager-api.yaml @@ -584,8 +584,7 @@ components: type: string description: The rendered data-quality fragment. QualityEngine: - type: string - enum: [SCHEMA, GREAT_EXPECTATIONS, JQ] + $ref: './components.yaml#/schemas/QualityEngine' CriterionType: type: string enum: [VALID_INVALID, IN_RANGE, GREATER_THAN, LESS_THAN] From 025bd5c4dc77d4b6e26b49ba1afd9ec4d418863f Mon Sep 17 00:00:00 2001 From: bzp99 Date: Wed, 9 Sep 2026 14:49:43 +0200 Subject: [PATCH 4/9] test(api): install the production plugins in tests --- .../dva/api/testutil/ApplicationTestHelpers.kt | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/ApplicationTestHelpers.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/ApplicationTestHelpers.kt index 81c3b0c1..2e7954a7 100644 --- a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/ApplicationTestHelpers.kt +++ b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/testutil/ApplicationTestHelpers.kt @@ -1,20 +1,13 @@ package hu.bme.mit.ftsrg.dva.api.testutil -import hu.bme.mit.ftsrg.dva.api.err.addHandlers +import hu.bme.mit.ftsrg.dva.api.installPlugins import io.ktor.client.* import io.ktor.client.plugins.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* -import io.ktor.server.plugins.calllogging.* -import io.ktor.server.plugins.statuspages.* -import io.ktor.server.resources.* import io.ktor.server.testing.* -import kotlinx.serialization.json.Json -import org.slf4j.event.Level import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation -import io.ktor.server.application.install as serverInstall -import io.ktor.server.plugins.contentnegotiation.ContentNegotiation as ServerContentNegotiation fun ApplicationTestBuilder.setupTestApplication(block: Application.() -> Unit = {}) = application { setupApplicationBase() @@ -27,9 +20,4 @@ fun ApplicationTestBuilder.createTestClient(block: HttpClientConfig<*>.() -> Uni block() } -private fun Application.setupApplicationBase() { - serverInstall(CallLogging) { level = Level.DEBUG } - serverInstall(StatusPages) { addHandlers() } - serverInstall(ServerContentNegotiation) { json(Json { explicitNulls = true }) } - serverInstall(Resources) -} \ No newline at end of file +private fun Application.setupApplicationBase() = installPlugins() From 1a250413e925a1a309730ec094dbc03851fb69c0 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Thu, 10 Sep 2026 17:43:25 +0200 Subject: [PATCH 5/9] test(api): pin that an unissued JWS is omitted instead of null --- .../test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt index 903ae023..6a150ec4 100644 --- a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt +++ b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt @@ -196,6 +196,7 @@ class AoVRoutesTest { assertNull(body.jws) assertFalse(body.evaluationPassing) assertEquals(listOf(failingEvalResult), body.evaluationResults) + assertFalse(bodyAsText().contains("jws"), "unissued JWS should be omitted, not null") } // Assert upstream requests From 53948af6d0359f385df138fc4c280b02605b8bb4 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Thu, 10 Sep 2026 21:02:11 +0200 Subject: [PATCH 6/9] feat(api): serve the DVA API spec rather than the legacy aggregate --- dva-api/Dockerfile | 4 +-- dva-api/README.md | 3 +- .../bme/mit/ftsrg/dva/api/route/docRoutes.kt | 33 +++++++++++++++++- .../api/src/main/resources/application.yaml | 4 ++- .../mit/ftsrg/dva/api/route/DocRoutesTest.kt | 34 +++++++++++++++++-- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/dva-api/Dockerfile b/dva-api/Dockerfile index eb87ab0d..6459f9e2 100755 --- a/dva-api/Dockerfile +++ b/dva-api/Dockerfile @@ -20,7 +20,7 @@ EXPOSE 9090 RUN mkdir /app/ COPY --from=build /home/gradle/src/*/build/libs/*.jar /app/ COPY --chmod=755 ./dva-api/docker/docker-entrypoint /app/ -COPY ./docs/spec/openapi.yaml /app/ -ENV DVA_OPENAPI_FILE=/app/openapi.yaml +COPY ./docs/spec/dva-api.yaml ./docs/spec/components.yaml /app/spec/ +ENV DVA_OPENAPI_FILE=/app/spec/dva-api.yaml ENTRYPOINT ["/app/docker-entrypoint"] HEALTHCHECK --interval=5s --timeout=5s CMD nc -z localhost 9090 diff --git a/dva-api/README.md b/dva-api/README.md index 6dcdf1c3..d0fdaf49 100644 --- a/dva-api/README.md +++ b/dva-api/README.md @@ -18,7 +18,8 @@ docker buildx build -t dva-api:latest -f dva-api/Dockerfile ./ > Run it from the parent directory (the repository root). > ```console > docker run --rm -it \ -> -v ./docs/spec/openapi.yaml:/home/gradle/docs/spec/openapi.yaml:ro \ +> -v ./docs/spec/dva-api.yaml:/home/gradle/docs/spec/dva-api.yaml:ro \ +> -v ./docs/spec/components.yaml:/home/gradle/docs/spec/components.yaml:ro \ > -v /run/docker.sock:/run/docker.sock \ > $(docker buildx build -q --no-cache --target build -f dva-api/Dockerfile ./) \ > gradle test diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/docRoutes.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/docRoutes.kt index 9d41b5c8..54286529 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/docRoutes.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/route/docRoutes.kt @@ -4,10 +4,24 @@ import io.ktor.http.* import io.ktor.server.application.* import io.ktor.server.html.* import io.ktor.server.plugins.swagger.* +import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.html.* +import java.io.File + +/** + * Names of the documents that may be served next to the OpenAPI file. + * + * Deliberately narrow: no dots in the stem and no separators, so no request can escape the + * spec directory. + */ +private val SPEC_SIBLING_NAME = Regex("""[A-Za-z0-9_-]+\.yaml""") + +private val YAML = ContentType.parse("application/yaml") fun Application.docRoutes(openapiPath: String) { + val specDir: File = File(openapiPath).canonicalFile.parentFile + routing { get("/") { val name = "DVA" @@ -32,5 +46,22 @@ fun Application.docRoutes(openapiPath: String) { } } swaggerUI("swagger", swaggerFile = openapiPath) + + /* + * Swagger UI resolves a relative `$ref` in the spec (eg `./components.yaml`) against the + * URL the spec itself was loaded from, ie `/swagger/`. The swaggerUI plugin only + * serves that one file, so the documents it references have to be served here; a constant + * path segment outranks this parameterised one, so the spec itself still goes to the + * plugin. + */ + get("/swagger/{fileName}") { + val name: String = call.parameters["fileName"].orEmpty() + val file: File = File(specDir, name).canonicalFile + if (!SPEC_SIBLING_NAME.matches(name) || file.parentFile != specDir || !file.isFile) { + call.respond(HttpStatusCode.NotFound) + return@get + } + call.respondText(file.readText(), YAML) + } } -} \ No newline at end of file +} diff --git a/dva-api/api/src/main/resources/application.yaml b/dva-api/api/src/main/resources/application.yaml index 284754a0..14880716 100644 --- a/dva-api/api/src/main/resources/application.yaml +++ b/dva-api/api/src/main/resources/application.yaml @@ -6,7 +6,9 @@ ktor: - hu.bme.mit.ftsrg.dva.api.ApplicationKt.module swagger: - openapiFile: "$DVA_OPENAPI_FILE:../docs/spec/openapi.yaml" + # Sibling files this spec `$ref`s (eg components.yaml) are served from the same + # directory; see docRoutes. + openapiFile: "$DVA_OPENAPI_FILE:../docs/spec/dva-api.yaml" postgres: url: "$DVA_POSTGRES_URL:postgresql://localhost:5432/dva" diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/DocRoutesTest.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/DocRoutesTest.kt index 3a2430ad..60813b39 100644 --- a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/DocRoutesTest.kt +++ b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/DocRoutesTest.kt @@ -2,12 +2,15 @@ package hu.bme.mit.ftsrg.dva.api.route import hu.bme.mit.ftsrg.dva.api.testutil.setupTestApplication import io.ktor.client.request.* +import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.server.testing.* import org.junit.jupiter.api.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +private const val OPENAPI_PATH = "../../docs/spec/dva-api.yaml" + class DocRoutesTest { @Test @@ -32,6 +35,33 @@ class DocRoutesTest { } } + @Test + fun `should serve the openapi file itself`() = testApplication { + setupApplication() + client.get("/swagger/dva-api.yaml").apply { + assertEquals(HttpStatusCode.OK, status) + assertTrue(bodyAsText().contains("openapi: 3.1")) + } + } + + /** The spec `$ref`s `./components.yaml`, which Swagger UI fetches relative to the spec URL. */ + @Test + fun `should serve documents the openapi file refers to`() = testApplication { + setupApplication() + client.get("/swagger/components.yaml").apply { + assertEquals(HttpStatusCode.OK, status) + assertTrue(bodyAsText().contains("EvaluationResult")) + } + } + + @Test + fun `should not serve files outside the spec directory`() = testApplication { + setupApplication() + listOf("/swagger/..%2F..%2Fbuild.gradle.kts", "/swagger/nonexistent.yaml", "/swagger/.env").forEach { + assertEquals(HttpStatusCode.NotFound, client.get(it).status, "should not have served $it") + } + } + private fun ApplicationTestBuilder.setupApplication() = - setupTestApplication { docRoutes(openapiPath = "../../docs/spec/openapi.yaml") } -} \ No newline at end of file + setupTestApplication { docRoutes(openapiPath = OPENAPI_PATH) } +} From 66932a565c7563f4d1601ec8a27957df99a2d9a7 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Thu, 10 Sep 2026 21:02:44 +0200 Subject: [PATCH 7/9] docs(api): refer to RFC 9457 rather than the obsoleted 7807 --- .../src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/errors.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/errors.kt b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/errors.kt index a923c0fc..28641009 100644 --- a/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/errors.kt +++ b/dva-api/model/src/main/kotlin/hu/bme/mit/ftsrg/dva/dto/api/errors.kt @@ -3,7 +3,7 @@ package hu.bme.mit.ftsrg.dva.dto.api import kotlinx.serialization.Serializable /** - * An RFC 7807 problem detail response DTO. + * An RFC 9457 problem detail response DTO. */ @Serializable data class ErrDTO( From 889c9be881b542df989864667b678e222686e34a Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 11 Sep 2026 16:49:57 +0200 Subject: [PATCH 8/9] feat(vla,vc): also serve shared schemas --- dva-vc-manager/Dockerfile | 2 +- dva-vc-manager/README.md | 2 +- dva-vc-manager/src/dva_vc_manager/audit.py | 18 ++++- dva-vc-manager/src/dva_vc_manager/main.py | 21 ++++- dva-vc-manager/src/dva_vc_manager/routes.py | 4 +- dva-vc-manager/tests/test_openapi_refs.py | 85 +++++++++++++++++++++ vla-manager-api/Dockerfile | 2 +- vla-manager-api/README.md | 6 +- vla-manager-api/src/vla_manager_api/main.py | 21 ++++- vla-manager-api/tests/test_openapi_refs.py | 85 +++++++++++++++++++++ 10 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 dva-vc-manager/tests/test_openapi_refs.py create mode 100644 vla-manager-api/tests/test_openapi_refs.py diff --git a/dva-vc-manager/Dockerfile b/dva-vc-manager/Dockerfile index e2cd42b4..d9b19d52 100644 --- a/dva-vc-manager/Dockerfile +++ b/dva-vc-manager/Dockerfile @@ -43,8 +43,8 @@ RUN apt-get update && \ # Copy built project COPY --from=build /app/.venv/ /app/.venv/ -# Hand-written OpenAPI spec -- served at /swagger/openapi.json COPY ./docs/spec/dva-vc-manager.yaml /app/openapi.yaml +COPY ./docs/spec/components.yaml /app/components.yaml ENV DVA_VC_MANAGER_OPENAPI_FILE=/app/openapi.yaml \ PYTHONUNBUFFERED=1 diff --git a/dva-vc-manager/README.md b/dva-vc-manager/README.md index 536fae37..b7711137 100644 --- a/dva-vc-manager/README.md +++ b/dva-vc-manager/README.md @@ -60,4 +60,4 @@ uv run dva-vc-manager # boot the service on :8000 | ``DVA_VC_MANAGER_HOST`` | ``0.0.0.0`` | Listen address | | ``DVA_VC_MANAGER_PORT`` | ``8000`` | Listen port | | ``DVA_VC_MANAGER_LOG_LEVEL`` | ``info`` | One of ``critical``, ``error``, ``warning``, ``info``, ``debug`` | -| ``DVA_VC_MANAGER_OPENAPI_FILE`` | ``/app/openapi.yaml`` | Hand-written spec served at ``/swagger``. Missing → FastAPI's generated schema. | +| ``DVA_VC_MANAGER_OPENAPI_FILE`` | ``/app/openapi.yaml`` | Hand-written spec served at ``/swagger``. Missing → FastAPI's generated schema. ``components.yaml`` next to it is served at ``/swagger/components.yaml``, where the spec's ``$ref``s point. | diff --git a/dva-vc-manager/src/dva_vc_manager/audit.py b/dva-vc-manager/src/dva_vc_manager/audit.py index b2677fed..f5563eb0 100644 --- a/dva-vc-manager/src/dva_vc_manager/audit.py +++ b/dva-vc-manager/src/dva_vc_manager/audit.py @@ -79,7 +79,10 @@ async def record_credential( self, credential_id: str, jws: str, request: dict[str, Any] ) -> CredentialAudit: entry = CredentialAudit( - id=uuid4(), credential_id=credential_id, jws=jws, request=request, + id=uuid4(), + credential_id=credential_id, + jws=jws, + request=request, created_at=datetime.now(timezone.utc), ) self._credentials.append(entry) @@ -89,7 +92,9 @@ async def record_verification( self, request: dict[str, Any], response: dict[str, Any] ) -> VerificationAudit: entry = VerificationAudit( - id=uuid4(), request=request, response=response, + id=uuid4(), + request=request, + response=response, created_at=datetime.now(timezone.utc), ) self._verifications.append(entry) @@ -142,7 +147,10 @@ async def record_credential( """INSERT INTO issued_credentials (id, credential_id, jws, request) VALUES ($1, $2, $3, $4::jsonb) RETURNING id, credential_id, jws, request, created_at""", - entry_id, credential_id, jws, json.dumps(request), + entry_id, + credential_id, + jws, + json.dumps(request), ) return CredentialAudit.from_row(row) @@ -155,7 +163,9 @@ async def record_verification( """INSERT INTO verification_audit_log (id, request, response) VALUES ($1, $2::jsonb, $3::jsonb) RETURNING id, request, response, created_at""", - entry_id, json.dumps(request), json.dumps(response), + entry_id, + json.dumps(request), + json.dumps(response), ) return VerificationAudit.from_row(row) diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py index 43f9194f..5a32629d 100644 --- a/dva-vc-manager/src/dva_vc_manager/main.py +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -3,12 +3,14 @@ from __future__ import annotations from contextlib import asynccontextmanager +from pathlib import Path from typing import Any, AsyncIterator import uvicorn import yaml -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, status from fastapi.openapi.utils import get_openapi +from fastapi.responses import FileResponse from .config import cfg from .dependencies import build_audit, build_key_store, build_whitelist @@ -71,6 +73,23 @@ def create_app() -> FastAPI: ) app.include_router(router) app.include_router(admin_router) + + @app.get("/swagger/components.yaml", include_in_schema=False) + async def shared_schemas() -> FileResponse: + """Serve the schemas the spec shares with the other DVA components. + + The spec refers to them as ``./components.yaml#/schemas/...``, which the + docs page resolves against the URL it loaded the spec from, ie next to + ``openapi_url``. Keeping the reference external means the shared + document stays the single definition rather than being copied in. + """ + shared = Path(cfg.openapi_file).parent / "components.yaml" + if not shared.is_file(): + raise HTTPException( + status.HTTP_404_NOT_FOUND, "No shared schemas available" + ) + return FileResponse(shared, media_type="application/yaml") + # Populating openapi_schema is what app.openapi() consults first, so # Swagger UI and ReDoc both render the hand-written spec. app.openapi_schema = _load_openapi_schema(app) diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py index 0aa15110..bceb0905 100644 --- a/dva-vc-manager/src/dva_vc_manager/routes.py +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -116,7 +116,9 @@ async def audited_response( request=request, response={ "status_code": status_code, - **(body if body is not None else response.model_dump(exclude_none=True)), + **( + body if body is not None else response.model_dump(exclude_none=True) + ), }, ) return response diff --git a/dva-vc-manager/tests/test_openapi_refs.py b/dva-vc-manager/tests/test_openapi_refs.py new file mode 100644 index 00000000..00b9b822 --- /dev/null +++ b/dva-vc-manager/tests/test_openapi_refs.py @@ -0,0 +1,85 @@ +""" +The spec shares schemas with the other DVA components through +``docs/spec/components.yaml`` and refers to them with a relative ``$ref``. The +docs page resolves that against the URL it loaded the spec from, so the shared +document has to be served next to ``openapi_url`` - otherwise the references +dangle and Swagger UI renders nothing for them. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml +from fastapi.testclient import TestClient + +from dva_vc_manager.main import create_app + +SPEC = Path(__file__).resolve().parents[2] / "docs" / "spec" / "dva-vc-manager.yaml" +SHARED = SPEC.parent / "components.yaml" + + +def _refs(node: Any) -> list[str]: + """Every ``$ref`` string anywhere in ``node``.""" + if isinstance(node, dict): + found = [node["$ref"]] if isinstance(node.get("$ref"), str) else [] + return found + [r for v in node.values() for r in _refs(v)] + if isinstance(node, list): + return [r for v in node for r in _refs(v)] + return [] + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> TestClient: + from dva_vc_manager.config import cfg + + monkeypatch.setattr(cfg, "openapi_file", str(SPEC)) + return TestClient(create_app()) + + +def test_the_spec_refers_to_the_shared_document() -> None: + """Guards the tests below: they would pass trivially if nothing were shared.""" + external = [ + r for r in _refs(yaml.safe_load(SPEC.read_text())) if "components.yaml" in r + ] + assert external, "expected the spec to share schemas via components.yaml" + assert all(r.startswith("./components.yaml#/schemas/") for r in external), external + + +def test_shared_document_is_served_next_to_the_spec(client: TestClient) -> None: + """``./components.yaml`` relative to /swagger/openapi.json is this path.""" + response = client.get("/swagger/components.yaml") + + assert response.status_code == 200 + assert yaml.safe_load(response.text) == yaml.safe_load(SHARED.read_text()) + + +def test_every_shared_ref_resolves_against_what_is_served(client: TestClient) -> None: + served = client.get("/swagger/openapi.json").json() + shared = yaml.safe_load(client.get("/swagger/components.yaml").text) + + for ref in set(_refs(served)): + document, _, pointer = ref.partition("#") + target = shared if document == "./components.yaml" else served + assert not document or document == "./components.yaml", ( + f"unknown document in {ref}" + ) + node: Any = target + for token in pointer.removeprefix("/").split("/"): + assert token in node, f"{ref} does not resolve ({token!r} missing)" + node = node[token] + + +def test_missing_shared_document_is_a_clean_404( + client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Mounting the spec without components.yaml must not yield a 500.""" + from dva_vc_manager.config import cfg + + spec = tmp_path / "openapi.yaml" + spec.write_text(SPEC.read_text(), encoding="utf-8") + monkeypatch.setattr(cfg, "openapi_file", str(spec)) + + assert TestClient(create_app()).get("/swagger/components.yaml").status_code == 404 diff --git a/vla-manager-api/Dockerfile b/vla-manager-api/Dockerfile index da717fbc..4dbbaefd 100644 --- a/vla-manager-api/Dockerfile +++ b/vla-manager-api/Dockerfile @@ -43,8 +43,8 @@ RUN apt-get update && \ # Copy built project COPY --from=build /app/.venv/ /app/.venv/ -# Hand-written OpenAPI spec -- served at /swagger/openapi.json COPY ./docs/spec/vla-manager-api.yaml /app/openapi.yaml +COPY ./docs/spec/components.yaml /app/components.yaml ENV VLA_MANAGER_OPENAPI_FILE=/app/openapi.yaml \ PYTHONUNBUFFERED=1 diff --git a/vla-manager-api/README.md b/vla-manager-api/README.md index b73d9e40..d67c0fc9 100644 --- a/vla-manager-api/README.md +++ b/vla-manager-api/README.md @@ -59,13 +59,17 @@ runs without a Postgres; state is lost on restart, so set the DSN for any deploy Not yet wired into `test-env/compose.yml`. The `Dockerfile` builds and runs standalone, and expects the spec mounted at `/app/openapi.yaml` (see `VLA_MANAGER_OPENAPI_FILE`). +The spec `$ref`s the schemas shared across the DVA components, so mount +`docs/spec/components.yaml` alongside it at `/app/components.yaml`; it is served at +`/swagger/components.yaml`, which is where those references resolve to. Without it the +spec is still served and `/swagger/components.yaml` answers `404`. ## Configuration (.env) | Var | Default | Purpose | |---|---|---| | `VLA_MANAGER_DB_URL` | *(empty)* | Postgres DSN, e.g. `postgresql://vla:vla@postgres:5432/vla`. Empty → non-persistent in-memory repositories. | -| `VLA_MANAGER_OPENAPI_FILE` | `/app/openapi.yaml` | Hand-written spec served at `/swagger`. Missing → FastAPI's generated schema. | +| `VLA_MANAGER_OPENAPI_FILE` | `/app/openapi.yaml` | Hand-written spec served at `/swagger`. Missing → FastAPI's generated schema. `components.yaml` next to it is served at `/swagger/components.yaml`, where the spec's `$ref`s point. | | `VLA_MANAGER_API_HOST` | `0.0.0.0` | Listen address | | `VLA_MANAGER_API_PORT` | `8000` | Listen port | | `VLA_MANAGER_API_LOG_LEVEL` | `info` | One of `critical`, `error`, `warning`, `info`, `debug` | diff --git a/vla-manager-api/src/vla_manager_api/main.py b/vla-manager-api/src/vla_manager_api/main.py index 9fc41326..4eb5f679 100644 --- a/vla-manager-api/src/vla_manager_api/main.py +++ b/vla-manager-api/src/vla_manager_api/main.py @@ -10,12 +10,14 @@ from __future__ import annotations from contextlib import asynccontextmanager +from pathlib import Path from typing import Any, AsyncIterator import uvicorn import yaml -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, status from fastapi.openapi.utils import get_openapi +from fastapi.responses import FileResponse from starlette.exceptions import HTTPException as StarletteHTTPException from .config import cfg @@ -80,6 +82,23 @@ def create_app() -> FastAPI: ) app.include_router(router) app.include_router(template_router) + + @app.get("/swagger/components.yaml", include_in_schema=False) + async def shared_schemas() -> FileResponse: + """Serve the schemas the spec shares with the other DVA components. + + The spec refers to them as ``./components.yaml#/schemas/...``, which the + docs page resolves against the URL it loaded the spec from, ie next to + ``openapi_url``. Keeping the reference external means the shared + document stays the single definition rather than being copied in. + """ + shared = Path(cfg.openapi_file).parent / "components.yaml" + if not shared.is_file(): + raise HTTPException( + status.HTTP_404_NOT_FOUND, "No shared schemas available" + ) + return FileResponse(shared, media_type="application/yaml") + # Render errors as the spec's {type, title} rather than FastAPI's # {"detail": ...}. Registered for Starlette's exception class so the # 404s and 405s the router itself raises are covered too. diff --git a/vla-manager-api/tests/test_openapi_refs.py b/vla-manager-api/tests/test_openapi_refs.py new file mode 100644 index 00000000..837539df --- /dev/null +++ b/vla-manager-api/tests/test_openapi_refs.py @@ -0,0 +1,85 @@ +""" +The spec shares schemas with the other DVA components through +``docs/spec/components.yaml`` and refers to them with a relative ``$ref``. The +docs page resolves that against the URL it loaded the spec from, so the shared +document has to be served next to ``openapi_url`` - otherwise the references +dangle and Swagger UI renders nothing for them. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +import yaml +from fastapi.testclient import TestClient + +from vla_manager_api.main import create_app + +SPEC = Path(__file__).resolve().parents[2] / "docs" / "spec" / "vla-manager-api.yaml" +SHARED = SPEC.parent / "components.yaml" + + +def _refs(node: Any) -> list[str]: + """Every ``$ref`` string anywhere in ``node``.""" + if isinstance(node, dict): + found = [node["$ref"]] if isinstance(node.get("$ref"), str) else [] + return found + [r for v in node.values() for r in _refs(v)] + if isinstance(node, list): + return [r for v in node for r in _refs(v)] + return [] + + +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> TestClient: + from vla_manager_api.config import cfg + + monkeypatch.setattr(cfg, "openapi_file", str(SPEC)) + return TestClient(create_app()) + + +def test_the_spec_refers_to_the_shared_document() -> None: + """Guards the tests below: they would pass trivially if nothing were shared.""" + external = [ + r for r in _refs(yaml.safe_load(SPEC.read_text())) if "components.yaml" in r + ] + assert external, "expected the spec to share schemas via components.yaml" + assert all(r.startswith("./components.yaml#/schemas/") for r in external), external + + +def test_shared_document_is_served_next_to_the_spec(client: TestClient) -> None: + """``./components.yaml`` relative to /swagger/openapi.json is this path.""" + response = client.get("/swagger/components.yaml") + + assert response.status_code == 200 + assert yaml.safe_load(response.text) == yaml.safe_load(SHARED.read_text()) + + +def test_every_shared_ref_resolves_against_what_is_served(client: TestClient) -> None: + served = client.get("/swagger/openapi.json").json() + shared = yaml.safe_load(client.get("/swagger/components.yaml").text) + + for ref in set(_refs(served)): + document, _, pointer = ref.partition("#") + target = shared if document == "./components.yaml" else served + assert not document or document == "./components.yaml", ( + f"unknown document in {ref}" + ) + node: Any = target + for token in pointer.removeprefix("/").split("/"): + assert token in node, f"{ref} does not resolve ({token!r} missing)" + node = node[token] + + +def test_missing_shared_document_is_a_clean_404( + client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Mounting the spec without components.yaml must not yield a 500.""" + from vla_manager_api.config import cfg + + spec = tmp_path / "openapi.yaml" + spec.write_text(SPEC.read_text(), encoding="utf-8") + monkeypatch.setattr(cfg, "openapi_file", str(spec)) + + assert TestClient(create_app()).get("/swagger/components.yaml").status_code == 404 From 61e512766d9ab7798509fb600dff4f8053629f15 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 11 Sep 2026 17:26:04 +0200 Subject: [PATCH 9/9] feat(api): answer malformed and unreadable bodies with problem details --- .../hu/bme/mit/ftsrg/dva/api/err/ErrType.kt | 2 + .../ftsrg/dva/api/err/MalformedRequestErr.kt | 12 +++ .../dva/api/err/UnsupportedMediaTypeErr.kt | 10 +++ .../bme/mit/ftsrg/dva/api/err/errHandling.kt | 58 ++++++++++--- .../mit/ftsrg/dva/api/route/AoVRoutesTest.kt | 86 +++++++++++++++++-- 5 files changed, 151 insertions(+), 17 deletions(-) create mode 100644 dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/MalformedRequestErr.kt create mode 100644 dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/UnsupportedMediaTypeErr.kt diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/ErrType.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/ErrType.kt index 8c30aad4..a327b5b4 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/ErrType.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/ErrType.kt @@ -6,6 +6,8 @@ import java.net.URI enum class ErrType(val uri: URI, val title: String) { ALREADY_EXISTS(URI("/errors/exists"), "Resource already exists"), NOT_FOUND(URI("/errors/not_found"), "Resource Not Found"), + BAD_REQUEST(URI("/errors/bad_request"), "Malformed request"), + UNSUPPORTED_MEDIA_TYPE(URI("/errors/unsupported_media_type"), "Unsupported media type"), BAD_GATEWAY(URI("/errors/bad_gateway"), "Upstream service error"), UNIMPLEMENTED(URI("/errors/unimplemented"), "Unimplemented feature"), UNKNOWN(URI("/errors/unknown"), "Unknown Error"), diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/MalformedRequestErr.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/MalformedRequestErr.kt new file mode 100644 index 00000000..85458de4 --- /dev/null +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/MalformedRequestErr.kt @@ -0,0 +1,12 @@ +package hu.bme.mit.ftsrg.dva.api.err + +import io.ktor.http.* + +class MalformedRequestErr(cause: Throwable) : APIErr( + ErrType.BAD_REQUEST, + HttpStatusCode.UnprocessableEntity, + "Malformed request body: ${cause.rootCause().message}", + cause, +) + +private fun Throwable.rootCause(): Throwable = generateSequence(this) { it.cause }.last() \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/UnsupportedMediaTypeErr.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/UnsupportedMediaTypeErr.kt new file mode 100644 index 00000000..0289e6fb --- /dev/null +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/UnsupportedMediaTypeErr.kt @@ -0,0 +1,10 @@ +package hu.bme.mit.ftsrg.dva.api.err + +import io.ktor.http.* + +class UnsupportedMediaTypeErr(cause: Throwable) : APIErr( + ErrType.UNSUPPORTED_MEDIA_TYPE, + HttpStatusCode.UnsupportedMediaType, + cause.message ?: "Unsupported content type", + cause, +) \ No newline at end of file diff --git a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/errHandling.kt b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/errHandling.kt index f86b83a1..3b81b197 100644 --- a/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/errHandling.kt +++ b/dva-api/api/src/main/kotlin/hu/bme/mit/ftsrg/dva/api/err/errHandling.kt @@ -1,12 +1,17 @@ package hu.bme.mit.ftsrg.dva.api.err import hu.bme.mit.ftsrg.dva.api.err.ErrType.NOT_FOUND +import hu.bme.mit.ftsrg.dva.api.err.ErrType.UNSUPPORTED_MEDIA_TYPE import hu.bme.mit.ftsrg.dva.dto.api.ErrDTO import hu.bme.mit.ftsrg.dva.log.RequestLogError import io.github.oshai.kotlinlogging.KotlinLogging +import io.ktor.http.* import io.ktor.http.HttpStatusCode.Companion.InternalServerError import io.ktor.http.HttpStatusCode.Companion.NotFound +import io.ktor.http.HttpStatusCode.Companion.UnsupportedMediaType import io.ktor.server.application.* +import io.ktor.server.plugins.* +import io.ktor.server.plugins.ContentTransformationException import io.ktor.server.plugins.statuspages.* import io.ktor.server.request.* import io.ktor.server.response.* @@ -16,6 +21,7 @@ private val logger = KotlinLogging.logger {} fun StatusPagesConfig.addHandlers() { exception(::handleException) status(NotFound) { call, _ -> handleUnrouted(call) } + status(UnsupportedMediaType) { call, _ -> handleUnsupportedMediaType(call) } } suspend fun handleException(call: ApplicationCall, cause: Throwable) { @@ -25,13 +31,28 @@ suspend fun handleException(call: ApplicationCall, cause: Throwable) { payload = mapOf("stacktrace" to cause.stackTraceToString()) } - val err = cause as? APIErr + val err: APIErr? = cause.asAPIErr() call.respond( status = err?.status ?: InternalServerError, - message = call.toErrorDTO(err?.type ?: ErrType.UNKNOWN, err), + message = call.toErrorDTO(err?.type ?: ErrType.UNKNOWN, err?.message), ) } +/** + * The [APIErr] this throwable represents, or `null` if it is not something the API knows how to + * report (i.e. it is a bug, and the client gets a 500). + * + * Ktor signals a client-side problem with its own exception types rather than with an [APIErr]; + * without translating them here, [StatusPagesConfig.exception] would catch them as generic + * throwables and report a malformed request body as an internal server error. + */ +private fun Throwable.asAPIErr(): APIErr? = when (this) { + is APIErr -> this + is UnsupportedMediaTypeException -> UnsupportedMediaTypeErr(this) + is BadRequestException, is ContentTransformationException -> MalformedRequestErr(this) + else -> null +} + /** * Handle a request to a path that is not routed in ktor. */ @@ -43,17 +64,34 @@ suspend fun handleUnrouted(call: ApplicationCall) { call.respond(message = call.toErrorDTO(NOT_FOUND), status = NotFound) } +/** + * Handle a request whose body content type no converter is registered for. + * + * Content negotiation answers these itself - with a bare 415 and no body - instead of throwing, so + * [handleException] never sees them; we replace that empty response with an [ErrDTO] here. + */ +suspend fun handleUnsupportedMediaType(call: ApplicationCall) { + val contentType: ContentType = call.request.contentType() + logger.atWarn { + message = "Handling unsupported media type error" + payload = mapOf("path" to call.request.path(), "contentType" to contentType.toString()) + } + call.respond( + status = UnsupportedMediaType, + message = call.toErrorDTO( + UNSUPPORTED_MEDIA_TYPE, + "Cannot read a request body of type $contentType; use ${ContentType.Application.Json}", + ), + ) +} + private fun ApplicationCall.toErrorDTO( type: ErrType, - cause: Throwable? = null, + detail: String? = null, ): ErrDTO = - errDTO(type) { - instance = request.path() - }.apply { - if (cause != null) detail = cause.message - } + errDTO(type) { instance = request.path() }.apply { this.detail = detail } fun Throwable.toRequestLogError(): RequestLogError { - val err = this as? APIErr - return RequestLogError(title = (err?.type ?: ErrType.UNKNOWN).title, detail = message) + val err: APIErr? = asAPIErr() + return RequestLogError(title = (err?.type ?: ErrType.UNKNOWN).title, detail = (err ?: this).message) } \ No newline at end of file diff --git a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt index 6a150ec4..776ab27e 100644 --- a/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt +++ b/dva-api/api/src/test/kotlin/hu/bme/mit/ftsrg/dva/api/route/AoVRoutesTest.kt @@ -25,9 +25,12 @@ import io.ktor.client.engine.mock.* import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.content.* +import io.ktor.http.* import io.ktor.http.HttpStatusCode.Companion.BadGateway import io.ktor.http.HttpStatusCode.Companion.NotFound import io.ktor.http.HttpStatusCode.Companion.OK +import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity +import io.ktor.http.HttpStatusCode.Companion.UnsupportedMediaType import io.ktor.server.application.* import io.ktor.server.testing.* import io.ktor.util.network.* @@ -112,6 +115,12 @@ private suspend fun HttpClient.postAttestation(request: AttestationRequest = att private suspend fun HttpClient.postVerification(request: AttestationVerificationRequest = verificationRequest): HttpResponse = post("/attestation/verify") { setBody(request) } +private suspend fun HttpClient.postRaw( + path: String, + body: String, + contentType: ContentType = ContentType.Application.Json, +): HttpResponse = post(path) { setBody(TextContent(body, contentType)) } + private fun upstreams( vla: MockResponder = { jsonResponse(emptyVLA) }, evaluate: MockResponder = { jsonResponse(listOf(passingEvalResult)) }, @@ -141,7 +150,7 @@ class AoVRoutesTest { private val sentRequests = mutableListOf() @BeforeEach - fun setupReqsRepoMocking() { + fun setup() { coEvery { reqsRepo.add(any()) } answers { firstArg() } } @@ -220,12 +229,7 @@ class AoVRoutesTest { // Act // Assert response payload - client.postAttestation().apply { - assertEquals(BadGateway, status) - val body: ErrDTO = body() - assertEquals(ErrType.BAD_GATEWAY.uri.toString(), body.type) - assertEquals(ErrType.BAD_GATEWAY.title, body.title) - } + client.postAttestation().assertIsError(BadGateway, ErrType.BAD_GATEWAY) // Assert upstream requests assertUpstreamEndpoints(*callOrder.take(callOrder.indexOf(failing) + 1).toTypedArray()) @@ -345,9 +349,74 @@ class AoVRoutesTest { // TODO: assert logged } + @ParameterizedTest(name = "{0}") + @MethodSource("bodyAcceptingPaths") + fun `malformed JSON body is rejected`(path: String) = testApplication { + // Arrange + setupApplication(upstreams()) + val client = createTestClient() + + // Act + // Assert response payload + client.postRaw(path, """{"exchangeID": """).assertIsError(UnprocessableEntity, ErrType.BAD_REQUEST) + + // Assert upstream requests + assertUpstreamEndpoints() + + // Assert db logging + assertNothingLogged() + } + + @ParameterizedTest(name = "{0}") + @MethodSource("bodyAcceptingPaths") + fun `body missing required fields is rejected`(path: String) = testApplication { + // Arrange + setupApplication(upstreams()) + val client = createTestClient() + + // Act + // Assert response payload + client.postRaw(path, "{}").assertIsError(UnprocessableEntity, ErrType.BAD_REQUEST) + + // Assert upstream requests + assertUpstreamEndpoints() + + // Assert db logging + assertNothingLogged() + } + + @ParameterizedTest(name = "{0}") + @MethodSource("bodyAcceptingPaths") + fun `body of an unsupported content type is rejected`(path: String) = testApplication { + // Arrange + setupApplication(upstreams()) + val client = createTestClient() + + // Act + // Assert response payload + client.postRaw(path, "not json at all", ContentType.Text.Plain) + .assertIsError(UnsupportedMediaType, ErrType.UNSUPPORTED_MEDIA_TYPE) + + // Assert upstream requests + assertUpstreamEndpoints() + + // Assert db logging + assertNothingLogged() + } + // TODO: handle potential invalid results from VC manager // TODO: handle potential other errors from processing + private suspend fun HttpResponse.assertIsError(expectedStatus: HttpStatusCode, expectedType: ErrType) { + assertEquals(expectedStatus, status) + val err: ErrDTO = body() + assertEquals(expectedType.uri.toString(), err.type) + assertEquals(expectedType.title, err.title) + assertNotNull(err.detail, "error response should explain what was wrong with the request") + } + + private fun assertNothingLogged() = coVerify(exactly = 0) { reqsRepo.add(any()) } + private fun assertLogged(expected: RequestLog) { assertEquals(expected, capturedLog().copy(id = expected.id)) } @@ -394,6 +463,9 @@ class AoVRoutesTest { } companion object { + @JvmStatic + fun bodyAcceptingPaths() = listOf("/attestation", "/attestation/verify") + @JvmStatic fun attestationUpstreamTransportFailures() = listOf( Arguments.of("VLA manager unreachable", Endpoint.vla(vlaUUID), { ConnectException() }),