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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project are documented in this file. The format is b

## [Unreleased]

### Fixed

- The OpenAPI document states the status each operation answers with, where it listed `200` for all of them: `201` for a new account or watched address, `201` or `200` for an ingested event, `202` with `Location` for a sync request. Every operation also lists the `ProblemDetail` errors all of them share, `400`, `401`, `403`, and `503`, under `application/problem+json`.
- The OpenAPI document covers `/api` only, so under `demo` it no longer lists the chain simulator. Its request schemas no longer name the validation getters `isAmountValid`, `isDirectionValid`, and `isStatusValid` as required fields.

## [0.4.0] - 2026-09-23

### Added
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ When the app is running:
- Swagger UI: [http://localhost:18080/swagger-ui.html](http://localhost:18080/swagger-ui.html)
- OpenAPI JSON: [http://localhost:18080/v3/api-docs](http://localhost:18080/v3/api-docs)

The OpenAPI document declares HTTP Basic, so **Authorize** in Swagger UI takes a username and password and sends them with every call. Outside `local` and `test` both pages and every API call require them.
The OpenAPI document declares HTTP Basic, so **Authorize** in Swagger UI takes a username and password and sends them with every call. Outside `local` and `test` both pages and every API call require them. The document covers the `/api` operations only, with the status each one answers with and the `ProblemDetail` errors all of them share (`400`, `401`, `403`, `503`); `docs/api.md` section 14 lists the rest.

Current public endpoints:

Expand Down
1 change: 1 addition & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ Required cases:
| Migrations | Full Liquibase changelog applies on an empty PostgreSQL database |
| Accounts | `external_ref` uniqueness, valid statuses, nullable `external_ref` |
| Watched addresses | FK to account, FK to chain config, unique `chain_id + address + asset` |
| OpenAPI document | each operation's success status (`201`, `202` with `Location`, `201` or `200` for ingest), the shared `ProblemDetail` errors, no simulator path under `demo`, and no validation getters in the request schemas |
| Observed transactions | natural key uniqueness, invalid enum checks, non-negative checks |
| First ingest | creates one observed transaction and one matching outbox event |
| Duplicate ingest | creates no second transaction and no second outbox event |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import com.example.assetsync.application.account.AccountApplicationService
import com.example.assetsync.application.account.CreateAccountCommand
import com.example.assetsync.application.account.RegisterWatchedAddressCommand
import com.example.assetsync.application.account.WatchedAddressApplicationService
import io.swagger.v3.oas.annotations.headers.Header
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import jakarta.validation.Valid
import java.net.URI
import java.util.UUID
Expand All @@ -31,6 +34,11 @@ class AccountController(
) {

@PostMapping
@ApiResponse(
responseCode = "201",
description = "The account was created.",
headers = [Header(name = "Location", description = "The URL of the new account.", schema = Schema(type = "string", format = "uri"))],
)
fun createAccount(
@Valid @RequestBody request: CreateAccountRequest,
): ResponseEntity<AccountResponse> {
Expand All @@ -50,6 +58,7 @@ class AccountController(
accountApplicationService.getAccount(accountId).toResponse()

@PostMapping("/{accountId}/addresses")
@ApiResponse(responseCode = "201", description = "The watched address was registered.")
fun registerWatchedAddress(
@PathVariable accountId: UUID,
@Valid @RequestBody request: RegisterWatchedAddressRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.example.assetsync.api.dto.toCommand
import com.example.assetsync.api.dto.toResponse
import com.example.assetsync.application.transaction.ObservedEventApplicationService
import com.example.assetsync.domain.model.TransitionOutcome
import io.swagger.v3.oas.annotations.responses.ApiResponse
import jakarta.validation.Valid
import java.security.Principal
import org.springframework.http.HttpStatus
Expand All @@ -27,6 +28,8 @@ class ObservedEventController(
* nobody and record `rest:anonymous`.
*/
@PostMapping
@ApiResponse(responseCode = "201", description = "A new observed transaction was stored.")
@ApiResponse(responseCode = "200", description = "The transaction existed: its state was updated, or the event changed nothing.")
fun ingestObservedEvent(
@Valid @RequestBody request: IngestObservedEventRequest,
principal: Principal?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package com.example.assetsync.api.controller
import com.example.assetsync.api.dto.SyncRunResponse
import com.example.assetsync.api.dto.toResponse
import com.example.assetsync.application.sync.SyncApplicationService
import io.swagger.v3.oas.annotations.headers.Header
import io.swagger.v3.oas.annotations.media.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
import java.net.URI
import java.util.UUID
import org.springframework.http.ResponseEntity
Expand All @@ -19,6 +22,11 @@ class SyncController(
) {

@PostMapping("/addresses/{addressId}/sync")
@ApiResponse(
responseCode = "202",
description = "The sync run is queued or already in flight; poll it at Location.",
headers = [Header(name = "Location", description = "The URL of the sync run.", schema = Schema(type = "string", format = "uri"))],
)
fun syncAddress(
@PathVariable addressId: UUID,
): ResponseEntity<SyncRunResponse> {
Expand All @@ -30,6 +38,11 @@ class SyncController(
}

@PostMapping("/accounts/{accountId}/sync")
@ApiResponse(
responseCode = "202",
description = "The sync run is queued or already in flight; poll it at Location.",
headers = [Header(name = "Location", description = "The URL of the sync run.", schema = Schema(type = "string", format = "uri"))],
)
fun syncAccount(
@PathVariable accountId: UUID,
): ResponseEntity<SyncRunResponse> {
Expand All @@ -49,3 +62,4 @@ class SyncController(
private fun syncRunLocation(syncRunId: UUID): URI =
URI.create("/api/v1/sync-runs/$syncRunId")
}

2 changes: 2 additions & 0 deletions src/main/kotlin/com/example/assetsync/api/dto/AccountDtos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.example.assetsync.api.dto
import com.example.assetsync.application.account.Account
import com.example.assetsync.application.account.WatchedAddress
import com.example.assetsync.application.account.WatchedAddressStatus
import com.fasterxml.jackson.annotation.JsonIgnore
import jakarta.validation.constraints.AssertTrue
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Pattern
Expand Down Expand Up @@ -47,6 +48,7 @@ data class UpdateWatchedAddressRequest(
val status: String = "",
) {
@get:AssertTrue(message = "status must be ACTIVE or DISABLED")
@get:JsonIgnore
val isStatusValid: Boolean
get() = status.isBlank() || WatchedAddressStatus.entries.any { it.name == status.trim() }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.example.assetsync.application.transaction.ObservedEventIngestionResul
import com.example.assetsync.domain.model.Direction
import com.example.assetsync.domain.model.TransactionStatus
import com.example.assetsync.domain.policy.AmountPolicy
import com.fasterxml.jackson.annotation.JsonIgnore
import jakarta.validation.constraints.AssertTrue
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
Expand Down Expand Up @@ -51,14 +52,17 @@ data class IngestObservedEventRequest(
val status: String = "",
) {
@get:AssertTrue(message = "amount must be a non-negative decimal string that fits numeric(38,18)")
@get:JsonIgnore
val isAmountValid: Boolean
get() = amount.isBlank() || parseAmountOrNull(amount) != null

@get:AssertTrue(message = "direction must be INBOUND or OUTBOUND")
@get:JsonIgnore
val isDirectionValid: Boolean
get() = direction.isBlank() || Direction.entries.any { it.name == direction.trim() }

@get:AssertTrue(message = "status must be SEEN, CONFIRMED, or REVERTED")
@get:JsonIgnore
val isStatusValid: Boolean
get() = status.isBlank() || TransactionStatus.entries.any { it.name == status.trim() }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,29 @@ package com.example.assetsync.config
import io.swagger.v3.oas.models.Components
import io.swagger.v3.oas.models.OpenAPI
import io.swagger.v3.oas.models.info.Info
import io.swagger.v3.oas.models.media.Content
import io.swagger.v3.oas.models.media.IntegerSchema
import io.swagger.v3.oas.models.media.MediaType
import io.swagger.v3.oas.models.media.ObjectSchema
import io.swagger.v3.oas.models.media.Schema
import io.swagger.v3.oas.models.media.StringSchema
import io.swagger.v3.oas.models.responses.ApiResponse
import io.swagger.v3.oas.models.security.SecurityRequirement
import io.swagger.v3.oas.models.security.SecurityScheme
import org.springdoc.core.customizers.OpenApiCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

private const val BASIC_AUTH_SCHEME = "basicAuth"
private const val PROBLEM_DETAIL_SCHEMA = "ProblemDetail"

/** The errors any API operation can answer with; the operation-specific ones are in docs/api.md. */
private val COMMON_PROBLEMS = linkedMapOf(
"400" to "The request is invalid: malformed JSON, a field or parameter that breaks its rules, or failed validation.",
"401" to "HTTP Basic credentials are missing or wrong, in every profile except local and test.",
"403" to "The caller's role may not call this operation, in every profile except local and test.",
"503" to "The database could not serve the request.",
)

@Configuration
class OpenApiConfiguration {
Expand Down Expand Up @@ -37,4 +54,36 @@ class OpenApiConfiguration {
),
)
.addSecurityItem(SecurityRequirement().addList(BASIC_AUTH_SCHEME))

/**
* Adds the errors in [COMMON_PROBLEMS] to every operation as `application/problem+json`, and
* the ProblemDetail schema they share. The success codes come from each controller; the
* operation-specific errors (404, 409, 429, ...) stay in `docs/api.md` section 14 instead of
* being repeated here.
*/
@Bean
fun commonProblemResponses(): OpenApiCustomizer =
OpenApiCustomizer { openApi ->
openApi.components.addSchemas(PROBLEM_DETAIL_SCHEMA, problemDetailSchema())
val content = Content().addMediaType(
org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON_VALUE,
MediaType().schema(Schema<Any>().`$ref`(PROBLEM_DETAIL_SCHEMA)),
)
openApi.paths.orEmpty().values.flatMap { it.readOperations() }.forEach { operation ->
COMMON_PROBLEMS.forEach { (status, description) ->
operation.responses.putIfAbsent(status, ApiResponse().description(description).content(content))
}
}
}

private fun problemDetailSchema(): Schema<Any> =
ObjectSchema()
.description("RFC 9457 problem detail. Some errors add domain properties, such as chainId.")
.addProperty("type", StringSchema().format("uri"))
.addProperty("title", StringSchema())
.addProperty("status", IntegerSchema())
.addProperty("detail", StringSchema())
.addProperty("instance", StringSchema().format("uri"))
.addProperty("requestId", StringSchema())
.additionalProperties(true)
}
3 changes: 3 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ management:

springdoc:
# Deliberately enabled in every profile: non-local profiles put both endpoints behind HTTP Basic.
# The API only: the demo simulator stands in for the bridge and is not part of it.
paths-to-match: /api/**
default-produces-media-type: application/json
api-docs:
path: /v3/api-docs
enabled: true
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.example.assetsync.integration

import com.example.assetsync.TestcontainersConfiguration
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import java.util.Base64
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.http.HttpHeaders
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status

/**
* The OpenAPI document is what a reviewer opens first, so it must say what the API does: the
* success code each operation answers with, the ProblemDetail errors every operation shares, and
* only the fields a client sends. `demo` is the profile that also serves the chain simulator,
* which must stay out of the document. The configuration matches ChainSimulatorIntegrationTests,
* so both share one context.
*/
@ActiveProfiles("demo")
@Import(TestcontainersConfiguration::class)
@SpringBootTest(
properties = [
"asset-sync.provider.base-url=http://localhost:1",
"asset-sync.outbox.scheduler.enabled=false",
"asset-sync.sync.recovery.enabled=false",
"asset-sync.sync.worker.enabled=false",
],
)
@AutoConfigureMockMvc
class OpenApiDocumentIntegrationTests(
@Autowired private val mockMvc: MockMvc,
@Autowired private val objectMapper: ObjectMapper,
) {

@Test
fun `each operation documents the status it answers with, and the simulator stays out`() {
val document = apiDocs()
val paths = document.path("paths")

assertTrue(paths.fieldNames().asSequence().all { it.startsWith("/api/") }, "paths: ${paths.fieldNames().asSequence().toList()}")
assertEquals(
mapOf(
"POST /api/v1/accounts" to listOf("201"),
"GET /api/v1/accounts/{accountId}" to listOf("200"),
"POST /api/v1/accounts/{accountId}/addresses" to listOf("201"),
"GET /api/v1/accounts/{accountId}/addresses" to listOf("200"),
"PATCH /api/v1/addresses/{addressId}" to listOf("200"),
"POST /api/v1/addresses/{addressId}/sync" to listOf("202"),
"POST /api/v1/accounts/{accountId}/sync" to listOf("202"),
"GET /api/v1/sync-runs/{syncRunId}" to listOf("200"),
"POST /api/v1/observed-events" to listOf("200", "201"),
),
operations(paths).mapValues { (_, operation) ->
operation.path("responses").fieldNames().asSequence().filter { it.startsWith("2") }.sorted().toList()
},
)
val createAccount = paths.path("/api/v1/accounts").path("post").path("responses").path("201")
assertTrue(createAccount.path("headers").has("Location"), createAccount.toString())
assertTrue(createAccount.path("content").path("application/json").path("schema").path("\$ref").asText().endsWith("/AccountResponse"), createAccount.toString())
val syncAddress = paths.path("/api/v1/addresses/{addressId}/sync").path("post").path("responses").path("202")
assertTrue(syncAddress.path("headers").has("Location"), syncAddress.toString())
}

@Test
fun `every operation shares the problem detail errors`() {
val document = apiDocs()

assertEquals("object", document.path("components").path("schemas").path("ProblemDetail").path("type").asText())
operations(document.path("paths")).forEach { (name, operation) ->
listOf("400", "401", "403", "503").forEach { status ->
val schema = operation.path("responses").path(status).path("content").path("application/problem+json").path("schema")
assertEquals("#/components/schemas/ProblemDetail", schema.path("\$ref").asText(), "$name $status")
}
}
}

@Test
fun `request schemas list only the fields a client sends`() {
val schemas = apiDocs().path("components").path("schemas")

listOf("IngestObservedEventRequest", "UpdateWatchedAddressRequest").forEach { name ->
val schema = schemas.path(name)
val fields = schema.path("properties").fieldNames().asSequence().toList() + schema.path("required").map { it.asText() }
assertTrue(fields.isNotEmpty() && fields.none { it.endsWith("Valid") }, "$name: $schema")
}
}

private fun apiDocs(): JsonNode {
val credentials = Base64.getEncoder().encodeToString("demo-reader:demo-reader-pw".toByteArray())
val body = mockMvc.perform(get("/v3/api-docs").header(HttpHeaders.AUTHORIZATION, "Basic $credentials"))
.andExpect(status().isOk)
.andReturn().response.contentAsString
return objectMapper.readTree(body)
}

private fun operations(paths: JsonNode): Map<String, JsonNode> =
paths.fields().asSequence().flatMap { (path, item) ->
item.fields().asSequence().map { (method, operation) -> "${method.uppercase()} $path" to operation }
}.toMap()
}
Loading