diff --git a/docs/requirements.md b/docs/requirements.md index 6fa813f..7714d57 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -584,8 +584,12 @@ The v0.2 review **reclassified several of these as blockers** (they gate a phase - **OQ-C** Feature-definition source of truth for hot-reload (P6): config file + watch, admin API, or a config table. Leaning: pluggable `FeatureDefinitionProvider` SPI. Interacts with version-skew ([ยง15 R10](#15-review-driven-revisions-v02)). *(Follow-on.)* -- **OQ-D ๐Ÿ”ด BLOCKER (phase 2)** Wire money representation (integer cents vs decimal string) โ€” blocks - OpenAPI client generation (FR-27, acceptance #10). Resolve before AR-3. โ†’ [ยง15 R17](#15-review-driven-revisions-v02). +- **OQ-D โœ… RESOLVED** Wire money representation. Money โ€” and every numeric `value` field (counts, + cardinalities, sums) โ€” crosses the wire as a **JSON string of a decimal integer** (`^-?\d+$`), + money in integer cents (e.g. `"14950"` = $149.50), matching the SPI's `BigDecimal` scale-0 cents + (P3). Chosen over a JSON number to avoid precision loss above 2โตยณ and float coercion in generated + clients, and over a decimal string for uniformity with the integer-cent SPI. The OpenAPI spec at + `velocity-api` (`Money`/`Value` schemas) encodes it. โ†’ [ยง15 R17](#15-review-driven-revisions-v02). - **OQ-E โœ… RESOLVED** DISTINCT dimension values are keyed-hashed at rest (per-namespace salt). โ†’ [ยง15 R11](#15-review-driven-revisions-v02). - **OQ-F ๐Ÿ”ด BLOCKER (SPI freeze, phase 1)** Seed/backfill schema โ€” `seed()` is a *published* SPI diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9753543..65bfa32 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -48,6 +48,10 @@ lettuce = "7.6.0.RELEASE" # Testing โ€” Testcontainers for the backend integration tests (Postgres now; Redis/LocalStack later). testcontainers = "1.21.4" +# OpenAPI parsing โ€” validates the committed velocity-api spec is well-formed OpenAPI 3.1 in a test +# (velocity-api OpenApiSpecTest). velocity-only; tracks the current latest 2.1.x. +swagger-parser = "2.1.22" + # Test bundle (wired in the test convention plugin). Pinned to pk-auth. junit-jupiter = "6.1.2" assertj = "3.27.7" @@ -86,6 +90,9 @@ hikaricp = { module = "com.zaxxer:HikariCP", version.ref = "hikaricp" } # Persistence โ€” Redis / Lettuce (phase-2 sliding/hot-path backend). lettuce-core = { module = "io.lettuce:lettuce-core", version.ref = "lettuce" } +# OpenAPI 3.1 parser โ€” used only in velocity-api's spec-validity test. +swagger-parser = { module = "io.swagger.parser.v3:swagger-parser", version.ref = "swagger-parser" } + # Testcontainers (backend integration tests). testcontainers-core = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" } testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" } diff --git a/velocity-api/build.gradle.kts b/velocity-api/build.gradle.kts index 710dad9..143d2f8 100644 --- a/velocity-api/build.gradle.kts +++ b/velocity-api/build.gradle.kts @@ -5,13 +5,14 @@ plugins { } description = - "velocity-api: the OpenAPI 3.1 document (source of truth for client generation) and the shared" + - " API DTOs used by the service tier and the generated Java client." + "velocity-api: the OpenAPI 3.1 document (source of truth); API DTOs are generated from it" + + " downstream (openapi-generator, AR-3)." dependencies { - // Shared API DTOs are Jackson-serialized (NFR-4) and nullability-annotated (NFR-17). - api(libs.jspecify) - api(libs.bundles.jackson) + // No hand-written DTOs live here โ€” the yaml under src/main/resources/openapi is the source of + // truth and clients/DTOs are generated from it downstream (AR-3). The only dependency is the + // OpenAPI parser used by OpenApiSpecTest to prove the committed spec is valid OpenAPI 3.1. + testImplementation(libs.swagger.parser) testRuntimeOnly(libs.logback.classic) } diff --git a/velocity-api/src/main/resources/openapi/velocity-engine-api.yaml b/velocity-api/src/main/resources/openapi/velocity-engine-api.yaml new file mode 100644 index 0000000..484b042 --- /dev/null +++ b/velocity-api/src/main/resources/openapi/velocity-engine-api.yaml @@ -0,0 +1,830 @@ +# SPDX-License-Identifier: BSD-3-Clause +# +# Velocity Engine HTTP API โ€” the single source of truth for the wire contract (AR-1). +# The Java client and shared API DTOs are GENERATED from this document downstream via +# openapi-generator (AR-3, P11); there are no hand-written DTOs in this module. +# +# The wire schemas mirror the frozen velocity-spi model (docs/adr/0009). Where the SPI uses a +# BigDecimal `value`, the wire uses a JSON STRING of a decimal integer โ€” see the Value/Money +# schemas and OQ-D (docs/requirements.md ยง13): JSON numbers lose precision above 2^53 and are +# coerced to float by many generated clients, so every numeric aggregate crosses the wire as a +# base-10 integer string. +openapi: 3.1.0 + +info: + title: Velocity Engine API + version: 0.1.0 + description: >- + HTTP/JSON surface of the Velocity Engine โ€” a backend-agnostic counting substrate that records + events and returns their velocity (COUNT / SUM / DISTINCT over sliding & tumbling time windows). + It is a counting substrate, not a decisioning platform: the caller owns all thresholds and + decisions (docs/requirements.md ยง1, D1). + + + All numeric aggregate values (`Value`, `Money`) are JSON **strings** of a base-10 integer + (`^-?\d+$`) โ€” money in integer cents (P3), counts/cardinalities as integers โ€” to avoid + JSON-number precision loss (> 2^53) and float coercion in generated clients (OQ-D). + license: + name: BSD-3-Clause + identifier: BSD-3-Clause + +servers: + - url: "{scheme}://{host}/velocity" + description: Velocity Engine service (Dropwizard tier). Host/scheme are deployment-specific. + variables: + scheme: + default: https + enum: + - https + - http + host: + default: localhost:8080 + +security: + # API keys are the v1 default auth mechanism (P9, NFR-12). Every operation requires a valid key. + # Each key is bound server-side to an allowed-namespace set; a request whose {namespace} is outside + # the key's scope is denied with 403 (NFR-21 / ยง15 R4) โ€” the client never selects its own scope. + - ApiKeyAuth: [] + +tags: + - name: record + description: Hot-path event recording with read-your-write velocities (FR-6, AR-2). + - name: query + description: Batched velocity queries with no mutation (FR-6, FR-8). + - name: metadata + description: Backend capability and feature-definition discovery (FR-12, FR-41). + - name: admin + description: Administrative operations, rate-isolated from the hot path (FR-23, FR-33). + +paths: + /v1/{namespace}/record: + post: + tags: [record] + operationId: recordEvent + summary: Record an event and return the affected feature velocities. + description: >- + Records a single event for a subject and returns the post-increment velocities of every + feature the namespace's definitions fan the event out to (FR-1, FR-2, D4). The set of + features updated is decided **server-side** by the namespace's feature definitions โ€” the + caller does not enumerate features. A record with no matching fan-out is a no-op returning an + empty result (FR-4). Read-your-write strength is the backend's declared level, per feature. + parameters: + - $ref: "#/components/parameters/NamespacePath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RecordRequest" + responses: + "200": + description: Fan-out applied; per-feature apply status and velocities returned. + content: + application/json: + schema: + $ref: "#/components/schemas/ApplyResult" + "400": { $ref: "#/components/responses/Validation" } + "403": { $ref: "#/components/responses/ForbiddenNamespace" } + "404": { $ref: "#/components/responses/UnknownNamespace" } + "422": { $ref: "#/components/responses/UnprocessableRequest" } + "429": { $ref: "#/components/responses/RateLimited" } + "503": { $ref: "#/components/responses/BackendUnavailable" } + "504": { $ref: "#/components/responses/DeadlineExceeded" } + + /v1/{namespace}/query: + post: + tags: [query] + operationId: queryVelocities + summary: Query current velocities for a batch of tuples (no mutation). + description: >- + Returns the current velocity for each requested `(subject, aggregation, window)` tuple + without recording an event (FR-6). Results are aligned **positionally** with the request + tuples: `results[i]` is the outcome for `tuples[i]`, each a value-or-failure `FeatureResult` + (ADR 0009) โ€” a failing tuple never collapses the batch. + parameters: + - $ref: "#/components/parameters/NamespacePath" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/QueryRequest" + responses: + "200": + description: Per-tuple results, positionally aligned with the request tuples. + content: + application/json: + schema: + $ref: "#/components/schemas/QueryResponse" + "400": { $ref: "#/components/responses/Validation" } + "403": { $ref: "#/components/responses/ForbiddenNamespace" } + "404": { $ref: "#/components/responses/UnknownNamespace" } + "422": { $ref: "#/components/responses/UnprocessableRequest" } + "429": { $ref: "#/components/responses/RateLimited" } + "503": { $ref: "#/components/responses/BackendUnavailable" } + "504": { $ref: "#/components/responses/DeadlineExceeded" } + + /v1/{namespace}/capabilities: + get: + tags: [metadata] + operationId: getCapabilities + summary: Describe the active backend's declared capabilities. + description: >- + Returns the `BackendCapabilities` the namespace's active backend declares โ€” supported + windows/aggregations/exactness, retention, read-your-write level, distinct thresholds, and + fan-out caps (FR-12, ยง6.1). The engine surfaces these truthfully rather than papering over + backend differences. + parameters: + - $ref: "#/components/parameters/NamespacePath" + responses: + "200": + description: The active backend's declared capabilities. + content: + application/json: + schema: + $ref: "#/components/schemas/BackendCapabilities" + "403": { $ref: "#/components/responses/ForbiddenNamespace" } + "404": { $ref: "#/components/responses/UnknownNamespace" } + "503": { $ref: "#/components/responses/BackendUnavailable" } + + /v1/{namespace}/features: + get: + tags: [metadata] + operationId: listFeatures + summary: List the namespace's feature definitions (config view). + description: >- + Lists the feature definitions configured for the namespace so a rules layer can discover, + pin, and diff them (FR-41, ยง15 R13). Because definitions are hot-reloadable (P6) and every + feature value carries the `definitionVersionHash` it was computed under (FR-40), the optional + `version` filter narrows the listing to a specific definition-set version. + parameters: + - $ref: "#/components/parameters/NamespacePath" + - name: version + in: query + required: false + description: >- + Optional definition-set version/hash to filter by (FR-41). When omitted, the currently + active definitions are returned. + schema: + type: string + responses: + "200": + description: The namespace's feature definitions. + content: + application/json: + schema: + $ref: "#/components/schemas/FeatureDefinitionList" + "403": { $ref: "#/components/responses/ForbiddenNamespace" } + "404": { $ref: "#/components/responses/UnknownNamespace" } + + /v1/{namespace}/purge: + post: + tags: [admin] + operationId: purge + summary: Administratively purge a subject or the whole namespace. + description: >- + Erases stored velocity data for a single subject, or โ€” when `subject` is omitted โ€” for the + entire namespace (FR-23, e.g. GDPR erasure or test cleanup). This is an admin operation, + rate-isolated from the hot path (FR-33), and is accepted for asynchronous processing. + parameters: + - $ref: "#/components/parameters/NamespacePath" + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/PurgeRequest" + responses: + "202": + description: Purge accepted for processing. + "400": { $ref: "#/components/responses/Validation" } + "403": { $ref: "#/components/responses/ForbiddenNamespace" } + "404": { $ref: "#/components/responses/UnknownNamespace" } + "429": { $ref: "#/components/responses/Overloaded" } + +components: + + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: >- + API-key authentication (P9). Send the key in the `X-API-Key` header. Each key is bound + server-side to an allowed-namespace set; a request whose path `{namespace}` is outside the + key's scope is denied with 403 (NFR-21 / ยง15 R4). Cross-namespace access is never + client-selectable. + + parameters: + NamespacePath: + name: namespace + in: path + required: true + description: The tenancy scope all keys are bound by (D6, FR-19). Non-blank. + schema: + $ref: "#/components/schemas/Namespace" + + responses: + Validation: + description: The request was malformed or failed validation (mirrors FailureCode.VALIDATION). + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + example: + type: "https://velocity.codeheadsystems.com/problems/validation" + title: Validation error + status: 400 + detail: "subject.value must not be blank" + UnknownNamespace: + description: The namespace is not configured on this deployment. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + example: + type: "https://velocity.codeheadsystems.com/problems/unknown-namespace" + title: Unknown namespace + status: 404 + detail: "namespace 'acme' is not configured" + ForbiddenNamespace: + description: >- + The API key is not authorized for this namespace (NFR-21 / ยง15 R4). Cross-namespace access is + denied server-side. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + example: + type: "https://velocity.codeheadsystems.com/problems/forbidden-namespace" + title: Namespace forbidden for this key + status: 403 + detail: "API key is not bound to namespace 'acme'" + UnprocessableRequest: + description: >- + The request was well-formed but cannot be satisfied โ€” e.g. it targets a window the active + backend does not support (mirrors FailureCode.UNSUPPORTED_WINDOW, FR-13). + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + examples: + unsupportedWindow: + value: + type: "https://velocity.codeheadsystems.com/problems/unsupported-window" + title: Unsupported window + status: 422 + detail: "window PT5S/SLIDING is not supported by the active backend" + validation: + value: + type: "https://velocity.codeheadsystems.com/problems/validation" + title: Validation error + status: 422 + detail: "requestedWindows contains a window not declared by any feature" + RateLimited: + description: The caller exceeded an allowed request rate (AR-5). + headers: + Retry-After: + description: Seconds to wait before retrying. + schema: + type: integer + minimum: 0 + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + example: + type: "https://velocity.codeheadsystems.com/problems/rate-limited" + title: Rate limited + status: 429 + detail: "per-key request rate exceeded" + Overloaded: + description: >- + The service shed this request to protect itself / preserve per-namespace fairness rather than + queue unbounded (NFR-22, mirrors an OVERLOADED outcome). + headers: + Retry-After: + description: Seconds to wait before retrying. + schema: + type: integer + minimum: 0 + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + example: + type: "https://velocity.codeheadsystems.com/problems/overloaded" + title: Overloaded + status: 429 + detail: "load-shed: namespace concurrency cap reached" + BackendUnavailable: + description: >- + The active backend is down or partitioned (mirrors FailureCode.UNAVAILABLE, NFR-19). Distinct + from a deadline so a caller's fail-open/closed policy can differ. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + example: + type: "https://velocity.codeheadsystems.com/problems/backend-unavailable" + title: Backend unavailable + status: 503 + detail: "backend 'redis-hot' is unreachable" + DeadlineExceeded: + description: >- + The caller/server deadline elapsed before a result was available (mirrors + FailureCode.DEADLINE_EXCEEDED, NFR-19). Distinct from unavailable (AR-5). + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + example: + type: "https://velocity.codeheadsystems.com/problems/deadline-exceeded" + title: Deadline exceeded + status: 504 + detail: "backend did not respond within the request deadline" + + schemas: + + # ---- Scalars / value objects ------------------------------------------------------------- + + Namespace: + type: string + minLength: 1 + description: A first-class tenancy scope (D6, FR-19); non-blank. + example: acme + + Money: + type: string + pattern: '^-?\d+$' + description: >- + A monetary amount as a JSON **string** of integer **cents** (P3), e.g. `"14950"` = $149.50. + A string โ€” not a JSON number โ€” because cent amounts can exceed 2^53 and JSON numbers lose + precision / are coerced to float by generated clients (OQ-D). + example: "14950" + + Value: + type: string + pattern: '^-?\d+$' + description: >- + An aggregate value as a JSON **string** of a base-10 integer (OQ-D): a count for + COUNT/DISTINCT, or a sum in integer cents for SUM (P3). String-encoded for the same + precision-safety reason as `Money`. + example: "42" + + Subject: + type: object + description: The structured key an aggregation is computed for (P5). + required: [type, value] + properties: + type: + type: string + minLength: 1 + description: The subject type, e.g. `card`, `account`, `ip`. + example: card + value: + type: string + minLength: 1 + description: The subject value. + example: "1234" + additionalProperties: false + + AggregationType: + type: string + description: The kind of aggregation a feature computes (D2, FR-11). + enum: [COUNT, SUM, DISTINCT] + + Aggregation: + type: object + description: >- + What a feature aggregates. `dimension` is present **iff** `type` is `DISTINCT` (the named + dimension whose distinct values are counted, FR-11); it MUST be absent for COUNT/SUM. + required: [type] + properties: + type: + $ref: "#/components/schemas/AggregationType" + dimension: + type: [string, "null"] + minLength: 1 + description: The distinct dimension name; present only for `DISTINCT`. + example: ip + additionalProperties: false + + WindowType: + type: string + description: >- + How time is modeled for a window (D3, FR-14). `SLIDING` covers `[now - duration, now]`; + `TUMBLING` uses aligned fixed buckets. HLL-distinct is valid on `TUMBLING` only (ADR 0005). + enum: [SLIDING, TUMBLING] + + Window: + type: object + description: A time window of a given type and duration (FR-14). + required: [duration, type] + properties: + duration: + type: string + description: >- + The window span as an ISO-8601 duration (e.g. `PT1H`, `PT30M`, `P1D`); strictly + positive. + example: PT1H + type: + $ref: "#/components/schemas/WindowType" + additionalProperties: false + + WindowBounds: + type: object + description: The concrete `[start, end]` interval a returned value covers (FR-7). + required: [start, end] + properties: + start: + type: string + format: date-time + description: Inclusive start instant (backend clock is authority for sliding edges, FR-3). + end: + type: string + format: date-time + description: End instant; not before `start`. + additionalProperties: false + + Exactness: + type: string + description: >- + Whether a value is exact or approximate (FR-7). A window is `APPROXIMATE` if any constituent + bucket was served by an HLL sketch (ADR 0006). + enum: [EXACT, APPROXIMATE] + + ReadYourWriteLevel: + type: string + description: >- + The read-your-write guarantee under which a value was produced (ADR 0007, ยง15 R2). A declared + capability, not universal; stated per feature value. + enum: [ATOMIC, SNAPSHOT, BESTEFFORT] + + FailureCode: + type: string + description: >- + A distinguishable data-plane failure reason (ADR 0009, NFR-19) โ€” a `Failure` carries one of + these instead of a silent `0`. **Extensible**: clients MUST tolerate an unrecognized code by + treating it as a generic failure. + enum: + - UNAVAILABLE + - DEADLINE_EXCEEDED + - CARDINALITY_CAP_EXCEEDED + - UNSUPPORTED_WINDOW + - VALIDATION + - INTERNAL + + ApplyStatus: + type: string + description: The per-feature outcome of a record fan-out (FR-34, ADR 0009). + enum: [APPLIED, FAILED, SKIPPED] + + Feature: + type: object + description: >- + A named counter reference `(name, aggregation, windows)`, e.g. `card.count.1h` โ€” the feature + a value is for. + required: [name, aggregation, windows] + properties: + name: + type: string + minLength: 1 + example: card.count.1h + aggregation: + $ref: "#/components/schemas/Aggregation" + windows: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/Window" + additionalProperties: false + + FeatureValue: + type: object + description: >- + The successful value of a feature read/write with all decision-bearing metadata (ADR 0009). + required: [feature, window, value, exactness, readYourWriteLevel, windowBounds, asOf] + properties: + feature: + $ref: "#/components/schemas/Feature" + window: + $ref: "#/components/schemas/Window" + value: + $ref: "#/components/schemas/Value" + exactness: + $ref: "#/components/schemas/Exactness" + readYourWriteLevel: + $ref: "#/components/schemas/ReadYourWriteLevel" + definitionVersionHash: + type: [string, "null"] + description: >- + The feature-definition version the value was computed under (FR-40). Part of the frozen + shape (ADR 0009 rule 3) but **nullable until FR-40's behavior lands** โ€” may be null in v1. + windowBounds: + $ref: "#/components/schemas/WindowBounds" + asOf: + type: string + format: date-time + description: The backend-clock instant the value is as-of (FR-7). + additionalProperties: false + + ResultKind: + type: string + description: Discriminator tag for `FeatureResult` โ€” SUCCESS carries a value, FAILURE a code. + enum: [SUCCESS, FAILURE] + + FeatureResultSuccess: + type: object + description: A successful `FeatureResult` (ADR 0009). + required: [kind, value] + properties: + kind: + $ref: "#/components/schemas/ResultKind" + value: + $ref: "#/components/schemas/FeatureValue" + additionalProperties: false + + FeatureResultFailure: + type: object + description: >- + A distinguishable failure `FeatureResult` โ€” never a silent `0` (ADR 0009 rule 1). Lets a + synchronous caller choose fail-open vs fail-closed deterministically. + required: [kind, code] + properties: + kind: + $ref: "#/components/schemas/ResultKind" + code: + $ref: "#/components/schemas/FailureCode" + detail: + type: [string, "null"] + description: Optional human-readable detail. + additionalProperties: false + + FeatureResult: + description: >- + The frozen value-or-failure result of a data-plane read/write (ADR 0009): **either** a + success carrying a `FeatureValue` **or** a failure carrying a `FailureCode`. Discriminated by + the `kind` property. + oneOf: + - $ref: "#/components/schemas/FeatureResultSuccess" + - $ref: "#/components/schemas/FeatureResultFailure" + discriminator: + propertyName: kind + mapping: + SUCCESS: "#/components/schemas/FeatureResultSuccess" + FAILURE: "#/components/schemas/FeatureResultFailure" + + PerFeature: + type: object + description: >- + The per-feature outcome of a record fan-out: its apply status and value-or-failure result + (FR-34, ADR 0009). A partial failure is expressible alongside successes. + required: [feature, status, result] + properties: + feature: + $ref: "#/components/schemas/Feature" + status: + $ref: "#/components/schemas/ApplyStatus" + result: + $ref: "#/components/schemas/FeatureResult" + additionalProperties: false + + ApplyResult: + type: object + description: "The aggregate result of a record fan-out: one `PerFeature` per touched feature." + required: [perFeature] + properties: + perFeature: + type: array + description: Per-feature outcomes; empty when the event matched no fan-out rule (FR-4). + items: + $ref: "#/components/schemas/PerFeature" + additionalProperties: false + + # ---- Requests ---------------------------------------------------------------------------- + + RecordRequest: + type: object + description: >- + An event to record. The features updated are decided server-side by the namespace's feature + definitions (fan-out, FR-1/FR-18) โ€” the caller does not enumerate features. + required: [subject] + properties: + subject: + $ref: "#/components/schemas/Subject" + dimensions: + type: object + description: >- + RAW dimension values keyed by dimension name (e.g. `{ "ip": "203.0.113.7" }`). The + SERVICE keyed-hashes these at rest per FR-38 โ€” callers send raw values, **never** + pre-hashed members. + additionalProperties: + type: string + example: + ip: "203.0.113.7" + merchant: "m-88" + value: + $ref: "#/components/schemas/Money" + requestedWindows: + type: array + description: >- + Optional filter bounding which windows are returned, to cap payload size (FR-2). When + omitted, all fanned-out windows are returned. + items: + $ref: "#/components/schemas/Window" + additionalProperties: false + + QueryTuple: + type: object + description: A single read request `(subject, aggregation, window)` (FR-6). + required: [subject, aggregation, window] + properties: + subject: + $ref: "#/components/schemas/Subject" + aggregation: + $ref: "#/components/schemas/Aggregation" + window: + $ref: "#/components/schemas/Window" + additionalProperties: false + + QueryRequest: + type: object + description: A batch of tuples to read in one call (FR-8). + required: [tuples] + properties: + tuples: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/QueryTuple" + additionalProperties: false + + QueryResponse: + type: object + description: Per-tuple results, aligned **positionally** with the request tuples. + required: [results] + properties: + results: + type: array + description: "`results[i]` is the outcome for the request's `tuples[i]`." + items: + $ref: "#/components/schemas/FeatureResult" + additionalProperties: false + + PurgeRequest: + type: object + description: >- + Purge target (FR-23). Omit `subject` to purge the entire namespace; provide it to purge a + single subject. + properties: + subject: + $ref: "#/components/schemas/Subject" + additionalProperties: false + + # ---- Metadata / capabilities ------------------------------------------------------------- + + WindowSpec: + type: object + description: A supported window with its exactness and storage granularity (ยง6.1). + required: [window, exactness, granularity] + properties: + window: + $ref: "#/components/schemas/Window" + exactness: + $ref: "#/components/schemas/Exactness" + granularity: + type: string + description: The bucket granularity the backend stores this window at, ISO-8601 duration. + example: PT1M + additionalProperties: false + + BackendCapabilities: + type: object + description: >- + What the namespace's active backend can do (ยง6.1). The engine reads this to validate + requests and shape defaults; it is never hardcoded (P18). + required: + - aggregations + - windows + - distinctHllSliding + - distinctExactCardinalityClamp + - distinctHllThresholdDefault + - maxRetention + - readYourWriteLevel + - idempotencySupported + - seedSupported + - maxAtomicFanOut + properties: + aggregations: + type: array + description: Supported aggregation kinds. + items: + $ref: "#/components/schemas/AggregationType" + windows: + type: array + description: Supported window specs (duration/type/exactness/granularity). + items: + $ref: "#/components/schemas/WindowSpec" + distinctHllSliding: + type: boolean + description: >- + Always `false` โ€” HLL-distinct is invalid on sliding windows (ADR 0005). Present so the + negative can be asserted. + distinctExactCardinalityClamp: + $ref: "#/components/schemas/Value" + distinctHllThresholdDefault: + $ref: "#/components/schemas/Value" + maxRetention: + type: string + description: The retention ceiling bounding how far back windows reach (FR-22a), ISO-8601. + example: P30D + readYourWriteLevel: + $ref: "#/components/schemas/ReadYourWriteLevel" + idempotencySupported: + type: boolean + description: Whether the backend supports idempotent apply (FR-5, ยง15 R15). + seedSupported: + type: boolean + description: Whether the backend implements SeedSupport (ADR 0008). + maxAtomicFanOut: + type: integer + description: >- + The maximum number of intents applied atomically in one call (e.g. DynamoDB's 100-item + transaction cap, ยง15 R3). + additionalProperties: false + + FeatureDefinition: + type: object + description: >- + The configuration view of a feature (FR-16, FR-40, FR-41): its name, aggregation, windows, + the backend that provides it, and the definition version/hash it is stamped with. This is the + config-time counterpart of the runtime `Feature`. + required: [name, aggregation, windows, backend, definitionVersionHash] + properties: + name: + type: string + minLength: 1 + example: card.distinct.ip.1d + aggregation: + $ref: "#/components/schemas/Aggregation" + windows: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/Window" + backend: + type: string + minLength: 1 + description: The target backend that provides this feature (FR-16; a feature binds to one). + example: postgres-ref + definitionVersionHash: + type: string + description: The version/hash of the definition set this feature belongs to (FR-40). + additionalProperties: false + + FeatureDefinitionList: + type: object + description: The namespace's feature definitions (FR-41). + required: [features] + properties: + features: + type: array + items: + $ref: "#/components/schemas/FeatureDefinition" + additionalProperties: false + + # ---- Errors (RFC 9457) ------------------------------------------------------------------- + + Problem: + type: object + description: >- + An RFC 9457 problem detail (`application/problem+json`, AR-5). The `type` URI names the + problem class; documented values mirror the SPI FailureCodes and NFR-19/NFR-22 outcomes: + `unknown-namespace` (404), `forbidden-namespace` (403), `validation` (400/422), + `unsupported-window` (422), `backend-unavailable` (503), `deadline-exceeded` (504), + `overloaded` (429), `rate-limited` (429). + properties: + type: + type: string + format: uri + default: "about:blank" + description: A URI identifying the problem type. + example: "https://velocity.codeheadsystems.com/problems/unsupported-window" + title: + type: string + description: A short, human-readable summary of the problem type. + status: + type: integer + format: int32 + minimum: 100 + maximum: 599 + description: The HTTP status code. + detail: + type: [string, "null"] + description: A human-readable explanation specific to this occurrence. + instance: + type: [string, "null"] + format: uri + description: A URI identifying this specific occurrence. + additionalProperties: true diff --git a/velocity-api/src/test/java/com/codeheadsystems/velocity/api/OpenApiSpecTest.java b/velocity-api/src/test/java/com/codeheadsystems/velocity/api/OpenApiSpecTest.java new file mode 100644 index 0000000..e84c3bf --- /dev/null +++ b/velocity-api/src/test/java/com/codeheadsystems/velocity/api/OpenApiSpecTest.java @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: BSD-3-Clause +package com.codeheadsystems.velocity.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.ParseOptions; +import io.swagger.v3.parser.core.models.SwaggerParseResult; +import java.net.URL; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Verifies the committed OpenAPI document is valid OpenAPI 3.1 and encodes the contract the rest of + * the system generates against (AR-1/AR-3). If this fails, downstream client generation is building + * on a broken source of truth โ€” so the assertions below would fail if the spec were malformed or an + * endpoint/schema were dropped. + */ +class OpenApiSpecTest { + + private static final String SPEC_CLASSPATH = "openapi/velocity-engine-api.yaml"; + private static final String MONEY_STRING_PATTERN = "^-?\\d+$"; + + private static SwaggerParseResult result; + private static OpenAPI openapi; + + @BeforeAll + static void parseSpec() { + URL specUrl = OpenApiSpecTest.class.getClassLoader().getResource(SPEC_CLASSPATH); + assertThat(specUrl).as("spec must be on the classpath at %s", SPEC_CLASSPATH).isNotNull(); + ParseOptions options = new ParseOptions(); + options.setResolve(true); + result = new OpenAPIV3Parser().readLocation(specUrl.toString(), null, options); + openapi = result.getOpenAPI(); + } + + @Test + void parsesWithNoErrorsOrWarnings() { + // A nonempty messages list means the parser found problems (unresolved $ref, bad schema, โ€ฆ). + assertThat(result.getMessages()).isEmpty(); + assertThat(openapi).isNotNull(); + } + + @Test + void isOpenApi31() { + assertThat(openapi.getOpenapi()).startsWith("3.1"); + } + + @Test + void declaresAllFiveEndpoints() { + assertThat(openapi.getPaths()) + .containsKeys( + "/v1/{namespace}/record", + "/v1/{namespace}/query", + "/v1/{namespace}/capabilities", + "/v1/{namespace}/features", + "/v1/{namespace}/purge"); + } + + @Test + void recordAndQueryArePosts() { + assertThat(operation("/v1/{namespace}/record")).isNotNull(); + assertThat(openapi.getPaths().get("/v1/{namespace}/record").getPost()).isNotNull(); + assertThat(openapi.getPaths().get("/v1/{namespace}/query").getPost()).isNotNull(); + assertThat(openapi.getPaths().get("/v1/{namespace}/capabilities").getGet()).isNotNull(); + assertThat(openapi.getPaths().get("/v1/{namespace}/features").getGet()).isNotNull(); + assertThat(openapi.getPaths().get("/v1/{namespace}/purge").getPost()).isNotNull(); + } + + @Test + void moneyAndValueAreIntegerStrings() { + Schema money = schema("Money"); + Schema value = schema("Value"); + assertThat(typeOf(money)).isEqualTo("string"); + assertThat(money.getPattern()).isEqualTo(MONEY_STRING_PATTERN); + assertThat(typeOf(value)).isEqualTo("string"); + assertThat(value.getPattern()).isEqualTo(MONEY_STRING_PATTERN); + } + + @Test + void featureResultIsADiscriminatedOneOf() { + Schema featureResult = schema("FeatureResult"); + assertThat(featureResult.getOneOf()) + .as("FeatureResult must be a value-or-failure oneOf (ADR 0009)") + .hasSize(2); + assertThat(featureResult.getDiscriminator()).isNotNull(); + assertThat(featureResult.getDiscriminator().getPropertyName()).isEqualTo("kind"); + } + + @Test + void topLevelSchemasArePresent() { + assertThat(openapi.getComponents().getSchemas()) + .containsKeys( + "Subject", + "Aggregation", + "Window", + "WindowBounds", + "Money", + "Value", + "FeatureValue", + "FeatureResult", + "PerFeature", + "ApplyResult", + "QueryTuple", + "BackendCapabilities", + "FeatureDefinition", + "Problem"); + } + + @Test + void apiKeySecuritySchemeIsDeclaredAndGlobal() { + SecurityScheme scheme = openapi.getComponents().getSecuritySchemes().get("ApiKeyAuth"); + assertThat(scheme).isNotNull(); + assertThat(scheme.getType()).isEqualTo(SecurityScheme.Type.APIKEY); + assertThat(scheme.getIn()).isEqualTo(SecurityScheme.In.HEADER); + assertThat(scheme.getName()).isEqualTo("X-API-Key"); + // Applied globally (P9): the root security requirement references the scheme. + assertThat(openapi.getSecurity()).anySatisfy(req -> assertThat(req).containsKey("ApiKeyAuth")); + } + + @Test + void errorsUseProblemJson() { + // AR-5: error responses are RFC 9457 application/problem+json. + var validation = openapi.getComponents().getResponses().get("Validation"); + assertThat(validation).isNotNull(); + assertThat(validation.getContent()).containsKey("application/problem+json"); + } + + private static PathItem operation(String path) { + return openapi.getPaths().get(path); + } + + /** + * The single JSON type of a schema. OpenAPI 3.1 (JSON Schema 2020-12) parses {@code type} into a + * {@code types} set, leaving {@code getType()} null, so read whichever the parser populated. + */ + private static String typeOf(Schema schema) { + if (schema.getType() != null) { + return schema.getType(); + } + assertThat(schema.getTypes()).hasSize(1); + return schema.getTypes().iterator().next(); + } + + private static Schema schema(String name) { + Schema schema = openapi.getComponents().getSchemas().get(name); + assertThat(schema).as("schema %s must exist", name).isNotNull(); + return schema; + } +}