diff --git a/.github/workflows/api-spec-lint.yml b/.github/workflows/api-spec-lint.yml new file mode 100644 index 0000000..5d3d4f7 --- /dev/null +++ b/.github/workflows/api-spec-lint.yml @@ -0,0 +1,68 @@ +# API spec lint +# +# Lints this repo's OpenAPI/AsyncAPI spec against the GovStack Cross-BB API +# Design Guide, using the composite action in api-design-guide/linter/. This +# workflow is inherited as-is by Building Block (BB) repos instantiated from +# bb-template. Repositories without an API declare that explicitly in +# api/index.yaml; empty legacy swagger placeholders are not conformant. +# +# The linter always runs in conformance mode. Deterministic contract failures +# block, while heuristic findings remain advisory warnings by default. +# +# A BB repo may instead delete the inherited api-design-guide/linter/ folder +# and pin the action from this template's repo directly, e.g.: +# uses: GovStackWorkingGroup/bb-template/api-design-guide/linter@ +name: API spec lint + +on: + pull_request: + paths: + - "api/**" + - "spec/**/*.md" + - "api-design-guide/**" + - ".github/workflows/api-spec-lint.yml" + push: + branches: + - main + paths: + - "api/**" + - "spec/**/*.md" + - "api-design-guide/**" + - ".github/workflows/api-spec-lint.yml" + +jobs: + linter-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Test bundled API linter and coverage contract + working-directory: api-design-guide/linter + env: + COVERAGE_ENFORCE: "1" + run: | + npm ci + npm test + - name: Validate the AsyncAPI golden and its external references + run: npx --yes @asyncapi/cli@6.0.2 validate api-design-guide/linter/tests/golden/asyncapi-golden.yaml + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Validate the OpenAPI golden and its external references + run: | + python -m pip install openapi-spec-validator==0.9.0 + openapi-spec-validator api-design-guide/linter/tests/golden/openapi-golden.yaml + - name: Check generated rule index and guide links + run: | + python3 api-design-guide/tools/build_rules_index.py --check + python3 api-design-guide/tools/check_links.py + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Lint API spec + uses: ./api-design-guide/linter diff --git a/README.md b/README.md index e824f65..6bf5ca5 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,8 @@ and deployment from the `/spec` directory. ```sh README.md /spec # the markdown files which are used to build the specification in GitBook -/api # the openapi specification +/api # the API inventory, contracts, coverage mapping, and common components +/api-design-guide # cross-BB API design guidance and validation tooling /test # the test plan and tests plan.md /examples # examples for deploying, configuring, and testing applications which implement the behaviors specified by this building block @@ -31,6 +32,21 @@ README.md /application-c ``` +## API contracts + +The template repository itself does not define a Building Block API surface, so +[`api/index.yaml`](api/index.yaml) declares `noApi`. When creating a Building +Block specification, replace that declaration with an inventory of every +OpenAPI, AsyncAPI, or normative protocol-standard surface. A Building Block +that genuinely has no API keeps an explicit `noApi` declaration. + +When one or more API surfaces are declared, map active interface requirements +to their operations, messages, or non-API verification in `api/coverage.yaml`. +Follow the +[GovStack Cross-BB API Design Guide](api-design-guide/README.md) and use its +[validation instructions](api-design-guide/guides/validating-your-spec.md) +before requesting review. Reusable schemas are available under `api/common/`. + ## ORB setup Documentation for ORB setup is available here: diff --git a/api-design-guide/.gitbook.yaml b/api-design-guide/.gitbook.yaml new file mode 100644 index 0000000..6acdd6a --- /dev/null +++ b/api-design-guide/.gitbook.yaml @@ -0,0 +1,5 @@ +root: ./ + +structure: + readme: README.md + summary: SUMMARY.md diff --git a/api-design-guide/.gitignore b/api-design-guide/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/api-design-guide/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/api-design-guide/1-introduction.md b/api-design-guide/1-introduction.md new file mode 100644 index 0000000..0d899d4 --- /dev/null +++ b/api-design-guide/1-introduction.md @@ -0,0 +1,150 @@ +--- +description: "Purpose, scope, layering, and enforcement model of the GovStack Cross-BB API Design Guide." +--- + +# 1. Introduction + +## 1.1 Purpose + +This guide defines the cross-BB interface rules that make GovStack API specifications internally consistent, machine-validatable, and implementable from the spec alone. + +## 1.2 Scope + +This guide constrains **what each BB's API specification declares**, and the **interface-level behavioural contract** that follows from it. + +The test for inclusion: *would two BB editors writing two different specs need to agree on this for an integrator to consume both without per-BB glue?* + +**In scope:** + +- REST APIs over HTTP, documented in OpenAPI 3.1. +- Event payloads and metadata, standardised as CloudEvents across HTTP and non-HTTP transports. +- Event-driven APIs over brokered transports and event streams, documented in AsyncAPI 3.0 (MQTT, AMQP, Kafka, WebSockets, SSE). +- Webhook subscriptions, documented via OpenAPI 3.1 `webhooks` for HTTP push. +- The schema-only companion files `govstack-openapi-common.yaml` (`Problem`, `ValidationProblem`, `FieldError`, and `PageInfo`) and `govstack-asyncapi-common.yaml` (the reusable event envelope and transport-neutral asynchronous error schemas). OpenAPI reuse is optional; BBs keep security schemes, parameters, headers, responses, examples, and Operation resources local. +- The repository-level API inventory (`api/index.yaml`, when the default canonical paths are insufficient) and functional-requirement traceability file (`api/coverage.yaml`). + +CloudEvents is the normative GovStack event contract across transports. It defines the common event envelope and stable event metadata (`id`, `source`, `type`, `time`, `data`, and extensions). AsyncAPI 3.0 is the required machine-readable documentation format for brokered event channels and event streams other than HTTP push webhooks: it describes channels, operations, messages, security, protocol bindings, examples, and delivery semantics. OpenAPI 3.1 `webhooks` remains the documentation format for HTTP push webhooks. Where AsyncAPI and CloudEvents overlap on event payload fields, CloudEvents takes precedence. GovStack domain events use structured CloudEvents JSON so the full event envelope is visible in the message payload and can be validated consistently across brokers. Protocol-specific depth for Kafka, MQTT, AMQP, WebSockets, and SSE is intentionally limited: BBs declare the relevant bindings where they affect the contract, while detailed broker-operation guidance is outside this guide or belongs in a protocol profile. This reflects the current BB landscape being predominantly REST, not a judgement that the ecosystem should remain so. + +The decision tree below summarises which artifact documents which kind of surface (informative). The document-level rules are in [§2](part-a/2-openapi-document-standards.md) and [§3](part-a/3-asyncapi-document-standards.md); the event rules are in [§16](part-d/16-cloudevents-and-webhooks.md) and [§17](part-d/17-asyncapi-channel-rules.md). + +```mermaid +flowchart TD + Q{"What kind of API surface?"} -->|"Synchronous HTTP request-response"| R["REST: OpenAPI 3.1
default api/openapi.yaml or api/index.yaml entry"] + Q -->|"HTTP push to subscriber URLs"| W["Webhooks: OpenAPI 3.1 webhooks section"] + Q -->|"Brokered channels or event streams
(MQTT, AMQP, Kafka, WebSockets, SSE)"| A["AsyncAPI 3.0
default api/asyncapi.yaml or api/index.yaml entry"] + Q -->|"Recognised protocol-native surface
(for example OIDC, OID4VCI, SDMX, OGC)"| S["Normative standard and discovery metadata
type: standard in api/index.yaml"] + W --> CE["Domain events use the CloudEvents v1.0.2 envelope"] + A --> CE +``` + +A BB MAY additionally expose surfaces under other industry standards (for example, an OGC API surface for spatial data, an OID4VCI surface for credential issuance, or an SDMX surface for statistical interchange). This is whole-surface adoption: the external standard governs that surface where it conflicts with this guide. [§1.7](#17-precedence-of-external-standards) covers the narrower case where an external standard or convention governs specific fields or envelopes inside a GovStack API surface. The guide's cross-cutting rules that do not conflict with the external standard still apply: [§8.6](part-b/8-headers.md#86-no-personal-data-in-addressable-locations) (no personal data in addressable locations), [§13](part-d/13-authentication-and-authorisation.md) (declaring a security scheme), [§11](part-c/11-errors.md) (a consistent error envelope where the external standard defines none), and [§18](part-d/18-compatibility-and-lifecycle.md) (versioning and deprecation). A surface is exempt from a specific rule only where the adopted standard actually governs that rule. + +**Out of scope:** + +- **Operational behaviour of a deployed BB** (token validation, certificate trust, key rotation, replay enforcement, audit logging, log redaction, algorithm allowlists, FAPI conformance, infrastructure). +- **Ecosystem governance** (ratification, enforcement, exception lifecycle, transition timelines, conformance levels, artifact ownership). These decisions belong to the GovStack governance process and the existing Specification Framework. +- Performance, SLOs, capacity planning. +- gRPC, GraphQL, file protocols, bulk media streaming. +- Implementation guidance for any specific BB. +- Design and maintenance of implementation conformance test packs. + +## 1.3 Relationship to existing GovStack documents + +This guide is a candidate GovStack specification intended to extend the Cross-Functional Requirements. It is not yet a conformant CFR extension: its protocol-native and non-HTTP interface rules propose changes to current CFR wording, tracked in [cfr-architecture issue #7](https://github.com/GovStackWorkingGroup/cfr-architecture/issues/7) and [issue #8](https://github.com/GovStackWorkingGroup/cfr-architecture/issues/8). Under the GovStack Specification Framework an extending specification may tighten or elaborate a cross-functional requirement but **MUST NOT** contradict or weaken one, and a requirement classified IMMUTABLE cannot be altered at all. Where a rule here inherits a cross-functional requirement it cites the requirement identifier, for example `govstack-cfr-data#req-2` in [§10.2](part-c/10-data-types-and-formats.md#102-rfc-3339-timestamps), so that the inheritance and its immutability are visible at the point of use. + +The guide's candidate specification identifier is `govstack-cfr-api`, its current version is `0.1.0-draft`, and its proposed parent is the developing `govstack-cfr` specification. The proposed relationships are: + +| Guide rules | CFR requirement | Relationship | +|---|---|---| +| §2.1–§2.4, §20.1 | `govstack-cfr-quality#req-4` | OpenAPI versions, discovery, and validation for applicable HTTP APIs. | +| §3, §16.1–§16.4, §17 | `govstack-cfr-architecture#req-19` | AsyncAPI and CloudEvents documentation for asynchronous interfaces. | +| §5.1, §18.1–§18.4 | `govstack-cfr-architecture#req-3` | Explicit versioning and compatibility. | +| §6.1, §6.3, §6.5, §14 | `govstack-cfr-architecture#req-5` | HTTP and idempotency behaviour. | +| §8.6, §17.3 | `govstack-cfr-architecture#req-6` | Data-protection constraints for addresses and metadata. | +| §8.4, §11.3, §17.8 | `govstack-cfr-architecture#req-13` | Trace Context and correlation. | +| §5.9 | `govstack-cfr-architecture#req-15` | Liveness contract. | +| §15.6, §16.1 | `govstack-cfr-architecture#req-14` | Callbacks and completion notifications. | +| §18.5, §18.7 | `govstack-cfr-architecture#req-17` | Consumer-visible deprecation. | +| §9.8, §9.9, §18.3, §18.6 | `govstack-cfr-architecture#req-18` | Tolerant-reader and additive-change behaviour. | +| §8.2, §19 | `govstack-cfr-quality#req-8` | Localisation. | +| §8.1, §13.1–§13.6, §17.10 | `govstack-cfr-security#req-3` | OIDC, OAuth, and service authentication declarations. | +| §11.2, §11.4 | `govstack-cfr-security#req-16` | Structured validation errors without exposed internals. | +| §10.11 | `govstack-cfr-data#req-1` | Immutable UTF-8 requirement. | +| §10.2 | `govstack-cfr-data#req-2` | Immutable UTC timestamp requirement. | +| §9.1, §10.3–§10.10 | `govstack-cfr-data#req-3` | Interoperable data representations and code lists. | +| §13.7 | `govstack-cfr-security#req-1` | Immutable protected-transport outcome. | + +These mappings are alignment references, not formal inheritance declarations. They become formal only after the conflicting parent wording is resolved and each normative guide rule has a CFR identifier and classifiers. This guide does not replace `govstack-cfr-architecture#req-7`; while that requirement remains in CFR, it continues to apply independently. Passing this guide's linter therefore does not by itself prove complete CFR conformance. + +## 1.4 Audience + +1. **Primary.** GovStack BB specification editors (the people writing the OpenAPI files). +2. **Secondary.** Implementers reading a BB specification to build a compliant system. +3. **Tertiary.** Country teams adopting BBs into a national architecture. + +The guide is written for lookup, not end-to-end reading. Sections are self-contained where possible. + +## 1.5 Language + +The guide uses RFC 2119 language: **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, **MAY**. A MUST rule is mandatory for conformance. The companion Spectral ruleset enforces the machine-checkable subset; rules that require judgement are assessed through review and the conformance process. Each rule is tagged with an enforcement class ([§1.9](#19-rule-enforcement-classes)) that says which side of that line it falls on, and [§1.8](#18-layering-what-this-guide-constrains) says which layer it constrains. A SHOULD rule is expected by default and requires written justification to skip. A MAY rule is optional. + +## 1.6 Exception process + +A BB editor **MAY** propose deviating from a **MUST** rule through the GovStack governance process. An exception **MUST NOT** weaken an inherited IMMUTABLE requirement, broaden an inherited EXTENSIBLE requirement, or replace a parent requirement that the GovStack Requirements Model does not permit replacing. An exception is effective only after approval and only for its recorded scope and lifetime. The canonical specification **MUST** declare each approved exception using the exact fields in [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version); an expired entry or one with a missing or syntactically invalid exception record URI does not suppress a rule. Until GovStack designates an approval authority and record system, no proposed exception can suppress conformance findings. + +## 1.7 Precedence of external standards + +Where this guide adopts an external standard or convention on the OpenAPI 3.1, CloudEvents, or AsyncAPI 3.0 surface, that standard or convention takes precedence over the guide's generic rules for the fields or payloads it covers. The known precedences are: + +- RFC 9457 fields inside error envelopes ([§11.1](part-c/11-errors.md#111-rfc-9457-problem-details); carve-out on [§9.2](part-c/9-json-conventions-and-naming.md#carve-out-from-92)). +- CloudEvents fields inside event envelopes ([§16.2](part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required); carve-out on [§9.2](part-c/9-json-conventions-and-naming.md#carve-out-from-92)). +- IANA-registered values, notably JOSE and COSE algorithm and curve names and media types, wherever a BB enumerates them (carve-out on [§9.7](part-c/9-json-conventions-and-naming.md#carve-out-from-97)). +- RFC 8615 well-known URIs, whose location is fixed at `/.well-known/` and therefore outside the versioned path scheme ([§5.10](part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints)). +- OAuth 2.0 / OIDC field shapes inside tokens, claims, and discovery documents ([§13.2](part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations)). +- Code-list standards (ISO 3166-1, ISO 4217, BCP 47, E.164) inside their respective fields ([§10.5](part-c/10-data-types-and-formats.md#105-e164-phone-numbers)–[§10.10](part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)). + +Each precedence **MUST** be named explicitly at the rule it overrides. Beyond the list above, BBs are encouraged to adopt well-established external standards rather than reinvent shapes for problems those standards already solve; the precedence list will grow as such cases are recognised. A BB adopting a standard not yet listed **SHOULD** declare the precedence in its spec at the rule it covers, and **SHOULD** raise the adoption with the API Working Group so it can be added to the next guide revision and propagated to other BBs facing the same problem. Where the adoption conflicts with an existing MUST rule, the exception process ([§1.6](#16-exception-process)) is the formal path. + +## 1.8 Layering: what this guide constrains + +This guide sits at the top of a stack, and keeping the layers distinct is what stops a design rule from drifting into an implementation decision: + +- **This guide** fixes the *shape* of every BB API (the rules below). It is generic and binds the spec author. +- **A BB specification** (the OpenAPI/AsyncAPI document for one BB) fills that shape with concrete content: which resources, fields, status codes, scopes, and error names that BB has. The guide constrains the shape; the BB spec owns the content. +- **An implementation profile** records the concrete deployment values a spec deliberately leaves open: server URLs, identity-provider endpoints, replay windows, retry counts, retention periods. The guide never fixes these. +- **A running deployment** has operational behaviour (token validation, key rotation, replay enforcement, logging) that no specification expresses. Those controls are outside this guide's scope ([§1.2](#12-scope)). + +```mermaid +flowchart TB + G["This guide
fixes the shape of every BB API"] --> S["BB specification
fills the shape with one BB's content"] + S --> P["Implementation profile
records deployment values the spec leaves open"] + P --> D["Running deployment
operational behaviour outside this guide"] +``` + +A rule earns a place in this guide only if it constrains the specification document. Every rule is therefore one of three kinds: + +1. **Spec-shape** rules constrain what the spec declares and are verifiable by reading the file (for example, [§9.2](part-c/9-json-conventions-and-naming.md#92-camelcase-field-names) camelCase, [§11.1](part-c/11-errors.md#111-rfc-9457-problem-details) `application/problem+json`). +2. **Documentation-obligation** rules require the spec to write down a contract whose value the guide does not itself fix (for example, [§14.3](part-d/14-idempotency.md#143-documented-replay-window), which makes the spec document its replay-window contract, and [§16.10](part-d/16-cloudevents-and-webhooks.md#1610-documented-delivery-failure-contract)). +3. **Behavioural-contract** rules state run-time behaviour an integrator relies on across BBs (for example, [§14.4](part-d/14-idempotency.md#144-replay-returns-original-response) idempotent replay, [§19.1](part-e/19-localisation.md#191-honour-the-request-language) honouring the request language). They are part of the interface contract but cannot be linted from the spec; they require implementation-level conformance testing. + +What the guide does **not** do is mandate a concrete deployment value or operational behaviour. Those concerns are outside its scope. Where a section mixes the three kinds, a **Layer** note at the top of that section says which rules fall where. + +This axis is orthogonal to the enforcement class of [§1.9](#19-rule-enforcement-classes). Spec-shape rules are usually `[M]` or `[M+R]`; behavioural-contract rules are `[R]`, because no linter can reach run-time behaviour, though the rule is no less binding and is still verified through conformance testing. + +## 1.9 Rule enforcement classes + +Each numbered rule carries an enforcement-class tag, shown as a bold badge at the start of the rule text, that says how conformance is checked: + +- **`[M]` Machine-checkable.** A linter (the GovStack Spectral ruleset, [§20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset)) or a schema validator can verify the rule from the specification document alone. Conformance is mechanical and unambiguous. +- **`[R]` Review.** The rule requires human judgement; no reliable automated check exists. Conformance is assessed through specification review and the conformance process. +- **`[M+R]` Partly machine-checkable.** A linter can verify the structural part (presence, shape, naming, declared values), but a human reviewer must confirm the semantic part: whether the right construct was used for the right meaning. + +The tag is guidance for the ruleset author and the conformance process, not part of the normative requirement: an `[M]` MUST and an `[R]` MUST are equally binding. The tag only marks where mechanical enforcement ends and review begins. Purely informative or scoping statements (for example [§12.10](part-c/12-pagination-filtering-sorting.md#1210-sparse-fieldsets-out-of-scope), [§16.9](part-d/16-cloudevents-and-webhooks.md#169-readiness-for-a-shared-signature-profile), [§18.6](part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields)) carry no tag. The machine-checkable subset that [§20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset) expects the Spectral ruleset to cover is the `[M]` rules plus the mechanical portion of the `[M+R]` rules. Where a rule depends on a referenced schema artifact, such as the conditional OpenAPI reuse in [§2.8](part-a/2-openapi-document-standards.md#28-conditional-vendored-openapi-schemas) or the AsyncAPI schemas in [§3.8](part-a/3-asyncapi-document-standards.md#38-pinned-vendored-asyncapi-components), that dependency is stated directly in the rule. + +## 1.10 Applicability and transition + +This guide applies in full to new API surfaces and to new major versions of existing surfaces. An already-published BB specification is not retroactively wire-incompatible merely because a newer guide exists. Existing surfaces **MUST** adopt requirements that do not change their public wire contract as soon as practical: canonical-file designation, `api/index.yaml` where needed, `api/coverage.yaml`, validation, complete metadata and descriptions, accurate examples, and security declarations that describe the behaviour already deployed. A change to paths, field names, representations, identifiers, status semantics, security behaviour, event addresses, or another consumer-visible contract **MUST NOT** be made in place solely to satisfy this guide; it **MUST** be released in the next major API version under [§18.4](part-d/18-compatibility-and-lifecycle.md#184-breaking-changes-bump-major-version). Until that major version, the existing surface documents the gap and follows the approved exception process rather than silently changing the wire contract. + +The GovStack governance process owns the transition schedule, conformance levels, and enforcement dates for existing BBs. Every canonical specification pins the exact guide and ruleset versions it uses under [§20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version); validation tooling **MUST NOT** silently substitute a newer compatible-looking version. + +The guide itself follows SemVer, including the current exact prerelease identifier `0.1.0-draft`. A guide patch release **MUST NOT** change which specifications conform; it may only correct prose or tooling defects without changing normative meaning. A guide minor release **MAY** add optional guidance, deprecate a rule, or relax a requirement, but **MUST NOT** add or strengthen a mandatory requirement. Adding or strengthening a **MUST** or **SHOULD**, removing a permitted behaviour, or otherwise making a previously conforming specification non-conforming **MUST** increment the guide major version. These rules apply even during the `0.x` drafting series so that exact conformance declarations remain meaningful. diff --git a/api-design-guide/README.md b/api-design-guide/README.md new file mode 100644 index 0000000..bcab1d6 --- /dev/null +++ b/api-design-guide/README.md @@ -0,0 +1,54 @@ +--- +description: "The rules every GovStack Building Block API specification must follow, so that BBs compose into a consistent national platform." +--- + +# GovStack Cross-BB API Design Guide + +{% hint style="warning" %} +**Status: DRAFT, for GovStack committee feedback.** This guide has not been +published or ratified. +{% endhint %} + +**Author:** Jeremi Joslin + +**Specification:** `govstack-cfr-api`
+**Version:** `0.1.0-draft`
+**Proposed parent:** `govstack-cfr` (candidate relationship pending CFR issues +[#7](https://github.com/GovStackWorkingGroup/cfr-architecture/issues/7) and +[#8](https://github.com/GovStackWorkingGroup/cfr-architecture/issues/8)) + +## Start here + +- **Editing a BB specification?** Run the [spec editor checklist](guides/spec-editor-checklist.md) against your spec, use [Rules at a glance](all-rules.md) to jump to any rule, and [validate mechanically](guides/validating-your-spec.md) before review. +- **Reviewing this draft for the committee?** [How to use this guide](how-to-use-this-guide.md) says what feedback is most useful at this stage. +- **Reviewing CFR alignment?** [§1.3](1-introduction.md#13-relationship-to-existing-govstack-documents) maps the guide to its proposed parent requirements and identifies the CFR changes needed for protocol-native and non-HTTP interfaces. +- **Pointing an AI coding agent at the rules?** The book ships a machine-readable index of every rule (`rules.yaml`, at the root of this folder in the repository); [Using this guide with AI agents](guides/using-with-ai-agents.md) has a ready-made instruction block for a BB repository. + +## Executive summary + +GovStack has standardised a great deal, but never a single API design guide that every Building Block follows. In its absence each BB team made reasonable local choices that, predictably, diverged. The rules below address gaps observed in published Building Block API specifications, not hypothetical ones. + +The GovStack Cross-BB API Design Guide defines the rules every Building Block API specification must follow, so that an integrator combining several BBs into a national digital platform sees consistent shapes for authentication, errors, identifiers, pagination, events, and lifecycle. It governs OpenAPI 3.1 REST surfaces, CloudEvents event payloads, OpenAPI webhooks, and AsyncAPI 3.0 documentation for brokered event channels and event streams. Operational behaviour (token validation, key rotation, audit logging) and ecosystem governance (ratification, enforcement, exception lifecycle) are out of scope. + +The pay-off is interoperability by construction. A canonical, machine-validatable, consistently shaped specification lets human implementers and AI coding agents generate correct clients or servers from the spec alone; an ambiguous or divergent one yields plausible-but-wrong code that quietly breaks interoperability. A guide precise enough for a linter to enforce is precise enough for an agent to implement. + +This draft is intended to be stress-tested immediately against live specification work, so the rules can be checked for clarity, enforceability, and implementability without excessive ceremony. Lessons from those pilots should feed back into v1.0 before ratification. + +The substantive rules establish: + +- Canonical, machine-validatable OpenAPI and AsyncAPI entrypoints at known locations ([§2](part-a/2-openapi-document-standards.md), [§3](part-a/3-asyncapi-document-standards.md)). +- Standard HTTP semantics and an unversioned `/health` endpoint, with versioned resource paths and consistent URL naming as recommended defaults rather than universal wire requirements ([§5](part-b/5-url-structure-and-versioning.md)–[§6](part-b/6-http-methods.md)). +- Standard HTTP status codes used consistently, with `ETag` / `If-Match` for optimistic concurrency ([§7](part-b/7-http-status-codes.md)). +- Standard headers for authentication, idempotency, localisation, correlation, and rate limiting; no personal data in URLs, channel addresses, routing keys, or message headers ([§8](part-b/8-headers.md), [§17](part-d/17-asyncapi-channel-rules.md)). +- Recommended `camelCase` for GovStack-owned JSON, plus normative RFC 3339 timestamps, decimal-string monetary amounts, E.164 phone numbers, and ISO code lists for country / currency / language ([§9](part-c/9-json-conventions-and-naming.md)–[§10](part-c/10-data-types-and-formats.md)). +- One ecosystem-wide HTTP error format based on RFC 9457 Problem Details, with a stable `https://govstack.global/problems/...` type URI, trace IDs, and field-level validation ([§11](part-c/11-errors.md)). +- Cursor-based pagination by default, with a single envelope shape for collection responses ([§12](part-c/12-pagination-filtering-sorting.md)). +- OAuth 2.0 + OIDC for citizen-facing operations, mutual TLS or OAuth client credentials for BB-to-BB calls ([§13](part-d/13-authentication-and-authorisation.md)). +- An `Idempotency-Key` contract for retry-safe POSTs ([§14](part-d/14-idempotency.md)). +- A single async pattern: `202 Accepted` plus a locally defined Operation resource with a common baseline shape ([§15](part-d/15-asynchronous-operations.md)). +- CloudEvents as the normative event envelope and type/source model across transports; OpenAPI `webhooks` and AsyncAPI 3.0 document the event surfaces ([§16](part-d/16-cloudevents-and-webhooks.md), [§17](part-d/17-asyncapi-channel-rules.md)). +- SemVer with major versions visible in the relevant surface contract, additive minor changes, deprecation and sunset headers ([§18](part-d/18-compatibility-and-lifecycle.md)). +- A single language model based on `Accept-Language` and `Content-Language` ([§19](part-e/19-localisation.md)). +- Mechanical validation: every BB spec MUST pass schema validation and the machine-checkable GovStack Spectral ruleset rules ([§20](part-e/20-conformance-and-validation.md)). + +Where the guide adopts an external standard or convention (RFC 9457, CloudEvents, OAuth 2.0 + OIDC, the health-check response convention, ISO code lists), that standard or convention takes precedence over the guide's generic rules ([§1.7](1-introduction.md#17-precedence-of-external-standards)). Rules use RFC 2119 keywords (see [§1.5](1-introduction.md#15-language)). diff --git a/api-design-guide/SUMMARY.md b/api-design-guide/SUMMARY.md new file mode 100644 index 0000000..ee3f823 --- /dev/null +++ b/api-design-guide/SUMMARY.md @@ -0,0 +1,52 @@ +# Table of contents + +* [GovStack Cross-BB API Design Guide](README.md) +* [How to use this guide](how-to-use-this-guide.md) +* [Rules at a glance](all-rules.md) +* [1. Introduction](1-introduction.md) + +## Part A. API artifacts + +* [2. OpenAPI document standards](part-a/2-openapi-document-standards.md) +* [3. AsyncAPI document standards](part-a/3-asyncapi-document-standards.md) +* [4. Documentation requirements](part-a/4-documentation-requirements.md) + +## Part B. The API surface + +* [5. URL structure and versioning](part-b/5-url-structure-and-versioning.md) +* [6. HTTP methods](part-b/6-http-methods.md) +* [7. HTTP status codes](part-b/7-http-status-codes.md) +* [8. Headers](part-b/8-headers.md) + +## Part C. Data + +* [9. JSON conventions and naming](part-c/9-json-conventions-and-naming.md) +* [10. Data types and formats](part-c/10-data-types-and-formats.md) +* [11. Errors](part-c/11-errors.md) +* [12. Pagination, filtering, sorting](part-c/12-pagination-filtering-sorting.md) + +## Part D. Behaviour + +* [13. Authentication and authorisation](part-d/13-authentication-and-authorisation.md) +* [14. Idempotency](part-d/14-idempotency.md) +* [15. Asynchronous operations](part-d/15-asynchronous-operations.md) +* [16. CloudEvents and webhooks](part-d/16-cloudevents-and-webhooks.md) +* [17. AsyncAPI channel documentation rules](part-d/17-asyncapi-channel-rules.md) +* [18. Compatibility and lifecycle](part-d/18-compatibility-and-lifecycle.md) + +## Part E. Cross-cutting + +* [19. Localisation](part-e/19-localisation.md) +* [20. Conformance and validation](part-e/20-conformance-and-validation.md) + +## Appendices + +* [Appendix. References](appendix/references.md) + +## Guides + +* [About these guides](guides/README.md) +* [Spec editor checklist](guides/spec-editor-checklist.md) +* [Validating your spec](guides/validating-your-spec.md) +* [Using this guide with AI agents](guides/using-with-ai-agents.md) +* [Maintaining this guide](guides/maintaining-this-guide.md) diff --git a/api-design-guide/all-rules.md b/api-design-guide/all-rules.md new file mode 100644 index 0000000..e7ab288 --- /dev/null +++ b/api-design-guide/all-rules.md @@ -0,0 +1,273 @@ +--- +description: "Every rule in the guide: enforcement class, RFC 2119 strength, surface, and a link." +--- + +# Rules at a glance + +This page is generated from the section pages by `tools/build_rules_index.py`; do not edit it by hand. Class legend: `[M]` machine-checkable, `[R]` review, `[M+R]` both; see [§1.9](1-introduction.md#19-rule-enforcement-classes). + +## 2. OpenAPI document standards + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [2.1](part-a/2-openapi-document-standards.md#21-openapi-31-required) | M | MUST | OpenAPI | OpenAPI 3.1 required | +| [2.2](part-a/2-openapi-document-standards.md#22-one-canonical-openapi-entrypoint) | M+R | MUST | OpenAPI | One canonical OpenAPI entrypoint | +| [2.3](part-a/2-openapi-document-standards.md#23-no-divergent-openapi-copies) | R | MUST | OpenAPI | No divergent OpenAPI copies | +| [2.4](part-a/2-openapi-document-standards.md#24-passes-openapi-spec-validator) | M | MUST | OpenAPI | Passes openapi-spec-validator | +| [2.5](part-a/2-openapi-document-standards.md#25-complete-info-block) | M | MUST | OpenAPI | Complete info block | +| [2.6](part-a/2-openapi-document-standards.md#26-meaningful-servers-block) | M+R | MUST | OpenAPI | Meaningful servers block | +| [2.7](part-a/2-openapi-document-standards.md#27-complete-operation-metadata) | M+R | MUST | OpenAPI | Complete operation metadata | +| [2.8](part-a/2-openapi-document-standards.md#28-conditional-vendored-openapi-schemas) | M+R | MUST | OpenAPI | Conditional vendored OpenAPI schemas | + +## 3. AsyncAPI document standards + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [3.1](part-a/3-asyncapi-document-standards.md#31-asyncapi-300-required) | M | MUST | AsyncAPI | AsyncAPI 3.0.0 required | +| [3.2](part-a/3-asyncapi-document-standards.md#32-one-canonical-asyncapi-entrypoint) | M+R | MUST | AsyncAPI | One canonical AsyncAPI entrypoint | +| [3.3](part-a/3-asyncapi-document-standards.md#33-no-divergent-asyncapi-copies) | R | MUST | AsyncAPI | No divergent AsyncAPI copies | +| [3.4](part-a/3-asyncapi-document-standards.md#34-passes-an-asyncapi-validator) | M | MUST | AsyncAPI | Passes an AsyncAPI validator | +| [3.5](part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block) | M | MUST | AsyncAPI | Complete AsyncAPI info block | +| [3.6](part-a/3-asyncapi-document-standards.md#36-servers-channels-operations-and-messages) | M+R | MUST | AsyncAPI | Servers channels operations and messages | +| [3.7](part-a/3-asyncapi-document-standards.md#37-complete-asyncapi-operation-metadata) | M+R | MUST | AsyncAPI | Complete AsyncAPI operation metadata | +| [3.8](part-a/3-asyncapi-document-standards.md#38-pinned-vendored-asyncapi-components) | M | MUST | AsyncAPI | Pinned vendored AsyncAPI components | +| [3.9](part-a/3-asyncapi-document-standards.md#39-json-schema-payload-conventions) | M | MUST | AsyncAPI | JSON Schema payload conventions | + +## 4. Documentation requirements + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [4.1](part-a/4-documentation-requirements.md#41-useful-schema-descriptions) | M | MUST | Universal | Useful schema descriptions | +| [4.2](part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums) | M+R | MUST | Universal | Examples for bodies and enums | +| [4.3](part-a/4-documentation-requirements.md#43-no-placeholder-text) | M+R | MUST | Universal | No placeholder text | +| [4.4](part-a/4-documentation-requirements.md#44-accurate-operation-descriptions) | R | MUST | Universal | Accurate operation descriptions | +| [4.5](part-a/4-documentation-requirements.md#45-api-surface-inventory) | M+R | MUST | Universal | API surface inventory | +| [4.6](part-a/4-documentation-requirements.md#46-functional-requirement-traceability) | M+R | MUST | Universal | Functional-requirement traceability | + +## 5. URL structure and versioning + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [5.1](part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path) | M | MUST | OpenAPI | Major version in the path | +| [5.2](part-b/5-url-structure-and-versioning.md#52-plural-noun-resources) | M+R | SHOULD | OpenAPI | Plural noun resources | +| [5.3](part-b/5-url-structure-and-versioning.md#53-kebab-case-path-segments) | M | SHOULD | OpenAPI | Kebab-case path segments | +| [5.4](part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting) | M | SHOULD | OpenAPI | Shallow path nesting | +| [5.5](part-b/5-url-structure-and-versioning.md#55-identifiers-as-path-parameters) | M+R | MUST | OpenAPI | Identifiers as path parameters | +| [5.6](part-b/5-url-structure-and-versioning.md#56-query-parameter-naming) | M | SHOULD | OpenAPI | Query parameter naming | +| [5.7](part-b/5-url-structure-and-versioning.md#57-no-verbs-in-crud-paths) | M+R | SHOULD | OpenAPI | No verbs in CRUD paths | +| [5.8](part-b/5-url-structure-and-versioning.md#58-actions-as-sub-resources) | R | SHOULD | OpenAPI | Actions as sub-resources | +| [5.9](part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint) | M+R | MUST | OpenAPI | Unversioned health endpoint | +| [5.10](part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints) | M | MUST | OpenAPI | Standard unversioned endpoints | + +## 6. HTTP methods + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [6.1](part-b/6-http-methods.md#61-get-is-safe-and-idempotent) | M+R | MUST | OpenAPI | GET is safe and idempotent | +| [6.2](part-b/6-http-methods.md#62-post-creates-or-performs-actions) | R | MUST | OpenAPI | POST creates or performs actions | +| [6.3](part-b/6-http-methods.md#63-put-replaces-the-entire-resource) | R | MUST | OpenAPI | PUT replaces the entire resource | +| [6.4](part-b/6-http-methods.md#64-patch-uses-a-registered-patch-format) | M+R | MUST | OpenAPI | PATCH uses a registered patch format | +| [6.5](part-b/6-http-methods.md#65-delete-response-semantics) | M+R | MUST | OpenAPI | DELETE response semantics | +| [6.6](part-b/6-http-methods.md#66-post-search-for-complex-queries) | M+R | MUST | OpenAPI | POST search for complex queries | +| [6.7](part-b/6-http-methods.md#67-bulk-mutation-needs-explicit-selection) | M+R | MUST | OpenAPI | Bulk mutation needs explicit selection | + +## 7. HTTP status codes + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [7.1](part-b/7-http-status-codes.md#71-200-for-successful-reads) | R | MUST | OpenAPI | 200 for successful reads | +| [7.2](part-b/7-http-status-codes.md#72-201-created-with-location) | M | MUST | OpenAPI | 201 Created with Location | +| [7.3](part-b/7-http-status-codes.md#73-202-accepted-for-async-operations) | M+R | MUST | OpenAPI | 202 Accepted for async operations | +| [7.4](part-b/7-http-status-codes.md#74-204-for-void-responses) | R | MUST | OpenAPI | 204 for void responses | +| [7.5](part-b/7-http-status-codes.md#75-400-for-malformed-requests) | R | MUST | OpenAPI | 400 for malformed requests | +| [7.6](part-b/7-http-status-codes.md#76-401-with-www-authenticate) | M+R | MUST | OpenAPI | 401 with WWW-Authenticate | +| [7.7](part-b/7-http-status-codes.md#77-403-when-not-authorised) | R | MUST | OpenAPI | 403 when not authorised | +| [7.8](part-b/7-http-status-codes.md#78-404-for-missing-resources) | R | MUST | OpenAPI | 404 for missing resources | +| [7.9](part-b/7-http-status-codes.md#79-409-for-state-conflicts) | R | MUST | OpenAPI | 409 for state conflicts | +| [7.10](part-b/7-http-status-codes.md#710-410-for-permanent-removal) | R | MUST | OpenAPI | 410 for permanent removal | +| [7.11](part-b/7-http-status-codes.md#711-422-for-semantic-errors) | R | MUST | OpenAPI | 422 for semantic errors | +| [7.12](part-b/7-http-status-codes.md#712-429-for-rate-limits) | R | MUST | OpenAPI | 429 for rate limits | +| [7.13](part-b/7-http-status-codes.md#713-server-errors-documented) | M | MUST | OpenAPI | Server errors documented | +| [7.14](part-b/7-http-status-codes.md#714-all-status-codes-declared) | M | MUST | OpenAPI | All status codes declared | +| [7.15](part-b/7-http-status-codes.md#715-412-for-failed-preconditions) | R | MUST | OpenAPI | 412 for failed preconditions | +| [7.16](part-b/7-http-status-codes.md#716-etag-and-if-none-match) | M+R | MUST | OpenAPI | ETag and If-None-Match | +| [7.17](part-b/7-http-status-codes.md#717-optimistic-concurrency-with-if-match) | M+R | MUST | OpenAPI | Optimistic concurrency with If-Match | +| [7.18](part-b/7-http-status-codes.md#718-405-with-allow-header) | M | MUST | OpenAPI | 405 with Allow header | +| [7.19](part-b/7-http-status-codes.md#719-415-for-unsupported-media-types) | M+R | MUST | OpenAPI | 415 for unsupported media types | +| [7.20](part-b/7-http-status-codes.md#720-no-store-on-error-responses) | M | SHOULD | OpenAPI | No-store on error responses | +| [7.21](part-b/7-http-status-codes.md#721-schemas-for-successful-response-bodies) | M | MUST | OpenAPI | Schemas for successful response bodies | + +## 8. Headers + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [8.1](part-b/8-headers.md#81-credentials-in-authorization-header) | M+R | MUST | OpenAPI | Credentials in Authorization header | +| [8.2](part-b/8-headers.md#82-accept-language-and-content-language) | M+R | MUST | OpenAPI | Accept-Language and Content-Language | +| [8.3](part-b/8-headers.md#83-idempotency-key-header-accepted) | M+R | MUST | OpenAPI | Idempotency-Key header accepted | +| [8.4](part-b/8-headers.md#84-w3c-trace-context-correlation) | M+R | MUST | OpenAPI | W3C Trace Context correlation | +| [8.5](part-b/8-headers.md#85-no-new-x--prefixed-headers) | M | MUST | OpenAPI | No new X- prefixed headers | +| [8.6](part-b/8-headers.md#86-no-personal-data-in-addressable-locations) | R | MUST | OpenAPI | No personal data in addressable locations | +| [8.7](part-b/8-headers.md#87-rate-limit-headers-declared) | M+R | MUST | OpenAPI | Rate-limit headers declared | + +## 9. JSON conventions and naming + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [9.1](part-c/9-json-conventions-and-naming.md#91-json-as-default-media-type) | M+R | MUST | Universal | JSON as default media type | +| [9.2](part-c/9-json-conventions-and-naming.md#92-camelcase-field-names) | M | SHOULD | Universal | camelCase field names | +| [9.3](part-c/9-json-conventions-and-naming.md#93-real-json-booleans) | M | MUST | Universal | Real JSON booleans | +| [9.4](part-c/9-json-conventions-and-naming.md#94-explicit-nullability) | M | MUST | Universal | Explicit nullability | +| [9.5](part-c/9-json-conventions-and-naming.md#95-no-spaces-or-non-ascii-names) | M | SHOULD | Universal | No spaces or non-ASCII names | +| [9.6](part-c/9-json-conventions-and-naming.md#96-avoid-abbreviations) | R | SHOULD | Universal | Avoid abbreviations | +| [9.7](part-c/9-json-conventions-and-naming.md#97-screaming-snake-case-enum-values) | M | MUST | Universal | Screaming snake case enum values | +| [9.8](part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas) | M | MUST | Universal | Forward-compatible schemas | +| [9.9](part-c/9-json-conventions-and-naming.md#99-no-closed-enums-for-growing-sets) | R | MUST | Universal | No closed enums for growing sets | +| [9.10](part-c/9-json-conventions-and-naming.md#910-govstack-extension-prefix) | M+R | MUST | Universal | GovStack extension prefix | +| [9.11](part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code) | M+R | MUST | Universal | Single registered BB code | + +## 10. Data types and formats + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [10.1](part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers) | M+R | MUST | Universal | Opaque server-generated identifiers | +| [10.2](part-c/10-data-types-and-formats.md#102-rfc-3339-timestamps) | M | MUST | Universal | RFC 3339 timestamps | +| [10.3](part-c/10-data-types-and-formats.md#103-rfc-3339-calendar-dates) | M | MUST | Universal | RFC 3339 calendar dates | +| [10.4](part-c/10-data-types-and-formats.md#104-decimal-string-monetary-amounts) | M+R | MUST | Universal | Decimal-string monetary amounts | +| [10.5](part-c/10-data-types-and-formats.md#105-e164-phone-numbers) | M+R | MUST | Universal | E.164 phone numbers | +| [10.6](part-c/10-data-types-and-formats.md#106-rfc-5322-email-addresses) | M+R | MUST | Universal | RFC 5322 email addresses | +| [10.7](part-c/10-data-types-and-formats.md#107-binary-uploads-and-base64-payloads) | M+R | MUST | Universal | Binary uploads and base64 payloads | +| [10.8](part-c/10-data-types-and-formats.md#108-iso-3166-1-country-codes) | M+R | MUST | Universal | ISO 3166-1 country codes | +| [10.9](part-c/10-data-types-and-formats.md#109-bcp-47-language-codes) | M+R | MUST | Universal | BCP 47 language codes | +| [10.10](part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes) | M+R | MUST | Universal | ISO 4217 currency codes | +| [10.11](part-c/10-data-types-and-formats.md#1011-utf-8-text-encoding) | M | MUST | Universal | UTF-8 text encoding | + +## 11. Errors + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [11.1](part-c/11-errors.md#111-rfc-9457-problem-details) | M | MUST | Universal | RFC 9457 problem details | +| [11.2](part-c/11-errors.md#112-stable-http-problem-type-uri) | M+R | MUST | Universal | Stable HTTP problem type URI | +| [11.3](part-c/11-errors.md#113-trace-identifier) | M | MUST | Universal | Trace identifier | +| [11.4](part-c/11-errors.md#114-field-level-errors-array) | M+R | MUST | Universal | Field-level errors array | +| [11.5](part-c/11-errors.md#115-stable-http-problem-fields-across-languages) | R | MUST | Universal | Stable HTTP problem fields across languages | +| [11.6](part-c/11-errors.md#116-transport-neutral-asynchronous-errors) | M+R | MUST | Universal | Transport-neutral asynchronous errors | + +## 12. Pagination, filtering, sorting + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [12.1](part-c/12-pagination-filtering-sorting.md#121-collections-must-paginate) | M+R | MUST | OpenAPI | Collections must paginate | +| [12.2](part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default) | M+R | MUST | OpenAPI | Cursor pagination by default | +| [12.3](part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope) | M | MUST | OpenAPI | Cursor pagination envelope | +| [12.4](part-c/12-pagination-filtering-sorting.md#124-documented-pagesize-bounds) | M+R | MUST | OpenAPI | Documented pageSize bounds | +| [12.5](part-c/12-pagination-filtering-sorting.md#125-optional-total-count) | R | MAY | OpenAPI | Optional total count | +| [12.6](part-c/12-pagination-filtering-sorting.md#126-offset-pagination-envelope) | M+R | MUST | OpenAPI | Offset pagination envelope | +| [12.7](part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention) | M | MUST | OpenAPI | Sort parameter convention | +| [12.8](part-c/12-pagination-filtering-sorting.md#128-simple-equality-filtering) | M+R | MUST | OpenAPI | Simple equality filtering | +| [12.9](part-c/12-pagination-filtering-sorting.md#129-complex-filtering-via-search) | M+R | MUST | OpenAPI | Complex filtering via search | +| [12.10](part-c/12-pagination-filtering-sorting.md#1210-sparse-fieldsets-out-of-scope) | — | — | OpenAPI | Sparse fieldsets out of scope | + +## 13. Authentication and authorisation + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [13.1](part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation) | M | MUST | Universal | Default security on every operation | +| [13.2](part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations) | M+R | MUST | Universal | OAuth and OIDC for citizen operations | +| [13.3](part-d/13-authentication-and-authorisation.md#133-distinct-scheme-for-bb-to-bb-calls) | M+R | MUST | Universal | Distinct scheme for BB-to-BB calls | +| [13.4](part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes) | M | MUST | Universal | Namespaced OAuth scopes | +| [13.5](part-d/13-authentication-and-authorisation.md#135-authorization-is-the-credential-channel) | M+R | MUST | Universal | Authorization is the credential channel | +| [13.6](part-d/13-authentication-and-authorisation.md#136-api-keys-only-for-operational-endpoints) | R | MUST | Universal | API keys only for operational endpoints | +| [13.7](part-d/13-authentication-and-authorisation.md#137-protected-transport) | M+R | MUST | Universal | Protected transport | + +## 14. Idempotency + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [14.1](part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts) | M+R | MUST | Universal | Idempotency-Key on non-idempotent POSTs | +| [14.2](part-d/14-idempotency.md#142-opaque-client-generated-keys) | R | MUST | Universal | Opaque client-generated keys | +| [14.3](part-d/14-idempotency.md#143-documented-replay-window) | R | MUST | Universal | Documented replay window | +| [14.4](part-d/14-idempotency.md#144-replay-returns-original-response) | R | MUST | Universal | Replay returns original response | +| [14.5](part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch) | R | MUST | Universal | Key reuse and fingerprint mismatch | +| [14.6](part-d/14-idempotency.md#146-naturally-idempotent-designs) | R | MUST | Universal | Naturally idempotent designs | + +## 15. Asynchronous operations + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [15.1](part-d/15-asynchronous-operations.md#151-202-with-operation-location) | M+R | MUST | OpenAPI | 202 with Operation Location | +| [15.2](part-d/15-asynchronous-operations.md#152-local-operation-resource-shape) | M+R | MUST | OpenAPI | Local Operation resource shape | +| [15.3](part-d/15-asynchronous-operations.md#153-documented-operation-lifecycle) | M+R | MUST | OpenAPI | Documented Operation lifecycle | +| [15.4](part-d/15-asynchronous-operations.md#154-polling-the-operation-resource) | M+R | MUST | OpenAPI | Polling the Operation resource | +| [15.5](part-d/15-asynchronous-operations.md#155-cancellation-via-cancel-sub-resource) | M+R | MUST | OpenAPI | Cancellation via cancel sub-resource | +| [15.6](part-d/15-asynchronous-operations.md#156-webhook-completion-notification) | R | SHOULD | OpenAPI | Webhook completion notification | +| [15.7](part-d/15-asynchronous-operations.md#157-documented-result-retention) | R | MUST | OpenAPI | Documented result retention | + +## 16. CloudEvents and webhooks + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [16.1](part-d/16-cloudevents-and-webhooks.md#161-event-surfaces-documented) | M+R | MUST | Event-driven | Event surfaces documented | +| [16.2](part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required) | M | MUST | Event-driven | CloudEvents envelope required | +| [16.3](part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types) | M | MUST | Event-driven | Reverse-DNS event types | +| [16.4](part-d/16-cloudevents-and-webhooks.md#164-stable-cloudevents-source) | M+R | MUST | Event-driven | Stable CloudEvents source | +| [16.5](part-d/16-cloudevents-and-webhooks.md#165-optional-signed-event-delivery) | R | MAY | Event-driven | Optional signed event delivery | +| [16.6](part-d/16-cloudevents-and-webhooks.md#166-signature-metadata-when-used) | R | MUST | Event-driven | Signature metadata when used | +| [16.7](part-d/16-cloudevents-and-webhooks.md#167-replay-detectable-signed-material) | R | MUST | Event-driven | Replay-detectable signed material | +| [16.8](part-d/16-cloudevents-and-webhooks.md#168-separate-experimental-signing-profile) | R | MUST | Event-driven | Separate experimental signing profile | +| [16.9](part-d/16-cloudevents-and-webhooks.md#169-readiness-for-a-shared-signature-profile) | — | — | Event-driven | Readiness for a shared signature profile | +| [16.10](part-d/16-cloudevents-and-webhooks.md#1610-documented-delivery-failure-contract) | R | MUST | Event-driven | Documented delivery-failure contract | +| [16.11](part-d/16-cloudevents-and-webhooks.md#1611-subscription-management-interfaces) | M+R | MUST | Event-driven | Subscription management interfaces | + +## 17. AsyncAPI channel documentation rules + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [17.1](part-d/17-asyncapi-channel-rules.md#171-send-and-receive-perspective) | M+R | MUST | AsyncAPI | Send and receive perspective | +| [17.2](part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses) | M+R | MUST | AsyncAPI | Stable logical channel IDs and native addresses | +| [17.3](part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels) | R | MUST | AsyncAPI | No personal data in channels | +| [17.4](part-d/17-asyncapi-channel-rules.md#174-declared-channel-parameters) | M+R | MUST | AsyncAPI | Declared channel parameters | +| [17.5](part-d/17-asyncapi-channel-rules.md#175-no-environment-names-in-addresses) | M+R | SHOULD | AsyncAPI | No environment names in addresses | +| [17.6](part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) | M | MUST | AsyncAPI | Structured CloudEvents JSON payloads | +| [17.7](part-d/17-asyncapi-channel-rules.md#177-shared-cloudevents-envelope-schema) | M | MUST | AsyncAPI | Shared CloudEvents envelope schema | +| [17.8](part-d/17-asyncapi-channel-rules.md#178-message-headers-and-idempotency-metadata) | M+R | MUST | AsyncAPI | Message headers and idempotency metadata | +| [17.9](part-d/17-asyncapi-channel-rules.md#179-message-localisation-headers) | M+R | MUST | AsyncAPI | Message localisation headers | +| [17.10](part-d/17-asyncapi-channel-rules.md#1710-security-schemes-cover-every-operation) | M+R | MUST | AsyncAPI | Security schemes cover every operation | +| [17.11](part-d/17-asyncapi-channel-rules.md#1711-duplicate-delivery-contract) | R | MUST | AsyncAPI | Duplicate delivery contract | +| [17.12](part-d/17-asyncapi-channel-rules.md#1712-ordering-only-when-promised) | R | MUST | AsyncAPI | Ordering only when promised | +| [17.13](part-d/17-asyncapi-channel-rules.md#1713-public-delivery-management-capabilities) | R | MUST | AsyncAPI | Public delivery-management capabilities | +| [17.14](part-d/17-asyncapi-channel-rules.md#1714-implementation-values-in-protocol-profiles) | R | MUST | AsyncAPI | Implementation values in protocol profiles | +| [17.15](part-d/17-asyncapi-channel-rules.md#1715-no-universal-delivery-extensions) | R | MUST | AsyncAPI | No universal delivery extensions | +| [17.16](part-d/17-asyncapi-channel-rules.md#1716-async-rejection-error-messages) | M+R | MUST | AsyncAPI | Async rejection error messages | +| [17.17](part-d/17-asyncapi-channel-rules.md#1717-declared-request-reply-correlation) | M+R | MUST | AsyncAPI | Declared request-reply correlation | +| [17.18](part-d/17-asyncapi-channel-rules.md#1718-correlated-completion-signals) | M+R | MUST | AsyncAPI | Correlated completion signals | +| [17.19](part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant) | M+R | MUST | AsyncAPI | Protocol bindings where relevant | +| [17.20](part-d/17-asyncapi-channel-rules.md#1720-representative-message-examples) | M+R | SHOULD | AsyncAPI | Representative message examples | + +## 18. Compatibility and lifecycle + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [18.1](part-d/18-compatibility-and-lifecycle.md#181-semver-versioning) | M | MUST | Universal | SemVer versioning | +| [18.2](part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel) | M | MUST | Universal | Major version in path or channel | +| [18.3](part-d/18-compatibility-and-lifecycle.md#183-backward-compatible-minor-changes) | M+R | MUST | Universal | Backward-compatible minor changes | +| [18.4](part-d/18-compatibility-and-lifecycle.md#184-breaking-changes-bump-major-version) | M+R | MUST | Universal | Breaking changes bump major version | +| [18.5](part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers) | M+R | MUST | Universal | Deprecation and Sunset headers | +| [18.6](part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields) | — | — | Universal | Clients ignore unknown fields | +| [18.7](part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata) | M+R | MUST | Universal | AsyncAPI deprecation metadata | + +## 19. Localisation + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [19.1](part-e/19-localisation.md#191-honour-the-request-language) | R | MUST | Universal | Honour the request language | +| [19.2](part-e/19-localisation.md#192-never-translate-stable-content) | R | MUST | Universal | Never translate stable content | +| [19.3](part-e/19-localisation.md#193-declared-default-language) | R | MUST | Universal | Declared default language | +| [19.4](part-e/19-localisation.md#194-declare-the-response-language) | M+R | MUST | Universal | Declare the response language | + +## 20. Conformance and validation + +| Rule | Class | Strength | Surface | Title | +| --- | --- | --- | --- | --- | +| [20.1](part-e/20-conformance-and-validation.md#201-every-file-passes-validation) | M | MUST | Universal | Every file passes validation | +| [20.2](part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset) | M | MUST | Universal | Passes the GovStack Spectral ruleset | +| [20.3](part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) | M | MUST | Universal | Declared guide conformance version | diff --git a/api-design-guide/appendix/references.md b/api-design-guide/appendix/references.md new file mode 100644 index 0000000..b71f295 --- /dev/null +++ b/api-design-guide/appendix/references.md @@ -0,0 +1,54 @@ +--- +description: "Standards and other sources referenced by the API Design Guide." +--- + +# Appendix. References + +## Normative standards + +- [RFC 2119, *Key words for use in RFCs to Indicate Requirement Levels*](https://www.rfc-editor.org/rfc/rfc2119) +- [RFC 8174, *Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words*](https://www.rfc-editor.org/rfc/rfc8174) +- [RFC 3339, *Date and Time on the Internet: Timestamps*](https://www.rfc-editor.org/rfc/rfc3339) +- [RFC 5322, *Internet Message Format*](https://www.rfc-editor.org/rfc/rfc5322) +- [RFC 6648, *Deprecating the "X-" Prefix in Application Protocols*](https://www.rfc-editor.org/rfc/rfc6648) +- [RFC 6749, *The OAuth 2.0 Authorization Framework*](https://www.rfc-editor.org/rfc/rfc6749) +- [RFC 6750, *OAuth 2.0 Bearer Token Usage*](https://www.rfc-editor.org/rfc/rfc6750) +- [RFC 6585, *Additional HTTP Status Codes*](https://www.rfc-editor.org/rfc/rfc6585) +- [RFC 6901, *JSON Pointer*](https://www.rfc-editor.org/rfc/rfc6901) +- [RFC 6902, *JSON Patch*](https://www.rfc-editor.org/rfc/rfc6902) +- [RFC 7396, *JSON Merge Patch*](https://www.rfc-editor.org/rfc/rfc7396) +- [RFC 8594, *The Sunset HTTP Header Field*](https://www.rfc-editor.org/rfc/rfc8594) +- [RFC 8615, *Well-Known Uniform Resource Identifiers*](https://www.rfc-editor.org/rfc/rfc8615) +- [RFC 8705, *OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens*](https://www.rfc-editor.org/rfc/rfc8705) +- [RFC 9110, *HTTP Semantics*](https://www.rfc-editor.org/rfc/rfc9110) +- [RFC 9325 / BCP 195, *Recommendations for Secure Use of TLS and DTLS*](https://www.rfc-editor.org/rfc/rfc9325) +- [RFC 9396, *OAuth 2.0 Rich Authorization Requests*](https://www.rfc-editor.org/rfc/rfc9396) +- [RFC 9449, *OAuth 2.0 Demonstrating Proof of Possession*](https://www.rfc-editor.org/rfc/rfc9449) +- [RFC 9457, *Problem Details for HTTP APIs*](https://www.rfc-editor.org/rfc/rfc9457) +- [RFC 9700 / BCP 240, *Best Current Practice for OAuth 2.0 Security*](https://www.rfc-editor.org/rfc/rfc9700) +- [RFC 9745, *The Deprecation HTTP Response Header Field*](https://www.rfc-editor.org/rfc/rfc9745) +- [OpenAPI Specification 3.1.0](https://spec.openapis.org/oas/v3.1.0.html), [3.1.1](https://spec.openapis.org/oas/v3.1.1.html), and [3.1.2](https://spec.openapis.org/oas/v3.1.2.html) +- [AsyncAPI Specification 3.0.0](https://www.asyncapi.com/docs/reference/specification/v3.0.0) +- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) +- [CloudEvents Specification 1.0.2, JSON format, and extensions](https://github.com/cloudevents/spec/tree/ce%40v1.0.2) +- [W3C Trace Context](https://www.w3.org/TR/trace-context/) +- [ISO 3166 country codes](https://www.iso.org/iso-3166-country-codes.html) +- [ISO 4217 currency codes](https://www.iso.org/iso-4217-currency-codes.html) +- [BCP 47 language tags](https://www.rfc-editor.org/info/bcp47) +- [ITU-T E.164, *The international public telecommunication numbering plan*](https://www.itu.int/rec/T-REC-E.164/en) +- [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html) +- IANA registries for [JOSE](https://www.iana.org/assignments/jose/jose.xhtml), [COSE](https://www.iana.org/assignments/cose/cose.xhtml), and [media types](https://www.iana.org/assignments/media-types/media-types.xhtml) + +## Pinned working drafts used as conventions + +The following documents are Internet-Drafts, not published standards. The +guide pins an exact revision so that later draft changes do not silently alter +its contract: + +- [`draft-ietf-httpapi-ratelimit-headers-11`, *RateLimit header fields for HTTP*](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers-11) +- [`draft-ietf-httpapi-idempotency-key-header-07`, *The Idempotency-Key HTTP Header Field*](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07) + +## Informative influences + +- [Google AIP-158, *Pagination*](https://google.aip.dev/158) +- [GraphQL Cursor Connections Specification](https://relay.dev/graphql/connections.htm) diff --git a/api-design-guide/guides/README.md b/api-design-guide/guides/README.md new file mode 100644 index 0000000..67e4079 --- /dev/null +++ b/api-design-guide/guides/README.md @@ -0,0 +1,18 @@ +--- +description: "What the Guides group is for, how it relates to the numbered rules, and what it will grow into at v1.0." +--- + +# About these guides + +The pages in this section are non-normative. They exist to make the numbered rules in §1–§20 faster to apply day to day: a checklist to run before review, commands to validate a spec, and notes on how to point an AI coding agent at this guide. Where anything here appears to conflict with a numbered section, the numbered section wins; these guides describe how to comply, not what compliance means. + +Four guides live here today: + +- [Spec editor checklist](../guides/spec-editor-checklist.md): a pre-submission checklist of the rules most often missed, grouped by specification type, each item linked to the rule it enforces. +- [Validating your spec](../guides/validating-your-spec.md): the actual commands to run against an OpenAPI or AsyncAPI file, and an honest account of what those commands do and do not catch. +- [Using this guide with AI agents](../guides/using-with-ai-agents.md): how `rules.yaml`, the published GitBook site, and a BB repository's `AGENTS.md`/`CLAUDE.md` fit together for agent-assisted spec work. +- [Maintaining this guide](../guides/maintaining-this-guide.md): where the canonical copy lives, how to edit a rule without breaking its anchor, and how to regenerate the machine-readable index. + +{% hint style="info" %} +This book is exact draft version `0.1.0-draft` and is not yet ratified. The matching draft Spectral ruleset and common OpenAPI/AsyncAPI component files ship in this repository. +{% endhint %} diff --git a/api-design-guide/guides/maintaining-this-guide.md b/api-design-guide/guides/maintaining-this-guide.md new file mode 100644 index 0000000..f616632 --- /dev/null +++ b/api-design-guide/guides/maintaining-this-guide.md @@ -0,0 +1,50 @@ +--- +description: "Where the canonical copy of this guide lives, how to edit a rule without breaking its anchor, and how to regenerate the machine-readable index." +--- + +# Maintaining this guide + +## Where the canonical copy lives + +The canonical copy of this guide lives in the GovStack `bb-template` repository, in the `/api-design-guide` folder. A BB repository instantiated from the template inherits a snapshot copy of that folder at the time of instantiation, and may delete it entirely if it does not want the guide tracked locally. Edits belong upstream, in `bb-template`, not in an individual BB's copy: a fix made directly to a BB's inherited copy will be silently overwritten the next time that BB pulls a template update, and it never flows back to `bb-template` on its own. If a BB editor finds an error while working locally, the fix has to be raised or applied against `bb-template` directly. + +## Editing a rule + +Edit the rule's body in place, on its page. Do not renumber existing rules, and do not change an existing heading's anchor id (the `` tag on that heading), even if the heading's visible text is reworded. External links, cross-references elsewhere in this book, and `rules.yaml` all depend on those anchors staying stable; changing one silently breaks every inbound reference. New rules within a section are appended at the end of that section's rule list, taking the next unused number. + +## Regenerating the machine layer + +Two scripts derive machine-readable artifacts from the page content and must be re-run whenever a page changes: + +```bash +python3 tools/build_rules_index.py +``` + +Rewrites `rules.yaml` and `all-rules.md` from the current state of the pages. Run this after editing, adding, or reordering any rule. + +```bash +python3 tools/build_rules_index.py --check +``` + +Verifies that `rules.yaml` and `all-rules.md` are still in sync with the pages, without rewriting them. Run this after any page edit; it is also the natural check to add to CI once this folder gets a CI hook (the template's CircleCI config does not run it today). + +```bash +python3 tools/check_links.py +``` + +Validates every internal link and anchor reference in the book, including `SUMMARY.md`. Run this alongside the index check whenever a page's headings or cross-references change. + +## Keeping the linter in step + +The [GovStack Spectral ruleset](../linter/README.md) enforces this guide mechanically, so a rule edit is not finished until the linter agrees with it. Adding, removing, or substantively rewording a rule usually means updating the matching Spectral rule in `linter/rulesets/`, its fixture pair in `linter/tests/fixtures/`, and the rule's entry in `linter/coverage.yaml` (which records how, or why not, every rule is covered). Two checks make forgetting this loud: + +```bash +cd linter && npm ci && npm test +COVERAGE_ENFORCE=1 node --test tests/coverage.test.mjs +``` + +The second command fails if `rules.yaml` and `coverage.yaml` disagree about the set of rule ids, or if `coverage.yaml` and the shipped rulesets disagree about which Spectral rules exist. A new guide rule that was never triaged for linting is therefore a test failure, not a silent gap. Rule `documentationUrl`s in the ruleset embed each rule's page and anchor, which is one more reason anchors must stay frozen. + +## Versioning the guide itself + +This guide is versioned with SemVer, per [§1.10](../1-introduction.md#110-applicability-and-transition), and its current exact identifier is `0.1.0-draft`. A patch release may correct prose or tooling without changing conformance. A minor release may add optional guidance, deprecate a rule, or relax a requirement. Adding or strengthening a mandatory rule, removing a permitted behaviour, or otherwise making a previously conforming specification non-conforming requires a major release. A ruleset release is separately versioned and every canonical spec pins both exact versions under [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version). Release notes begin with the first published version. diff --git a/api-design-guide/guides/spec-editor-checklist.md b/api-design-guide/guides/spec-editor-checklist.md new file mode 100644 index 0000000..f0d6d33 --- /dev/null +++ b/api-design-guide/guides/spec-editor-checklist.md @@ -0,0 +1,55 @@ +--- +description: "A pre-submission checklist of the rules most often missed, grouped by specification type, each item linked to the rule it enforces." +--- + +# Spec editor checklist + +Run this before submitting a BB specification for review. Each item links to the rule that governs it; read the rule if the phrase alone is not enough to act on. + +## Every specification + +- [ ] `info` has a SemVer `version`, `title`, and useful `description`; add `contact` when the contract is the right place for maintainer metadata. ([2.5](../part-a/2-openapi-document-standards.md#25-complete-info-block), [3.5](../part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block)) +- [ ] `servers` is non-empty and meaningful: no `localhost`, no personal machines, no fake production domains; reference specs use parameterised template URLs. ([2.6](../part-a/2-openapi-document-standards.md#26-meaningful-servers-block), [3.6](../part-a/3-asyncapi-document-standards.md#36-servers-channels-operations-and-messages)) +- [ ] Every operation has a stable `operationId` (or AsyncAPI operation key), its required contract references, and an accurate `description`; add `summary` and tags when useful for navigation. ([2.7](../part-a/2-openapi-document-standards.md#27-complete-operation-metadata), [3.7](../part-a/3-asyncapi-document-standards.md#37-complete-asyncapi-operation-metadata)) +- [ ] Add useful schema descriptions where names and structure do not make semantics clear. ([4.1](../part-a/4-documentation-requirements.md#41-useful-schema-descriptions)) +- [ ] Add representative request and response examples where useful; document enum values that are not self-explanatory. ([4.2](../part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums)) +- [ ] No placeholder text anywhere: no `TBD`, no `Lorem ipsum`, no `a, b, c`, no leftover content copied from another BB. ([4.3](../part-a/4-documentation-requirements.md#43-no-placeholder-text)) +- [ ] Canonical surfaces are discoverable at the default paths or through a valid `api/index.yaml`; `api/coverage.yaml` exactly traces every marked functional requirement to an operation, message, external contract, rationale, or tracked plan. ([4.5](../part-a/4-documentation-requirements.md#45-api-surface-inventory), [4.6](../part-a/4-documentation-requirements.md#46-functional-requirement-traceability)) +- [ ] A security scheme is declared and applied to every operation by default, with per-operation overrides explicit. ([13.1](../part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation)) +- [ ] `info.x-govstack-api-guide` pins exact guide and ruleset versions (`0.1.0-draft`) and every exception has all seven §20.3 fields: `rule`, `scope`, `rationale`, `record`, `reviewedBy`, `reviewedAt`, and `expiresAt`. ([20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)) +- [ ] The file passes its validator (see [Validating your spec](../guides/validating-your-spec.md)). ([20.1](../part-e/20-conformance-and-validation.md#201-every-file-passes-validation)) +- [ ] `info.version` follows SemVer. ([18.1](../part-d/18-compatibility-and-lifecycle.md#181-semver-versioning)) +- [ ] GovStack-owned JSON field names use the recommended `camelCase` convention consistently; fields imported from an external standard retain their standard spelling. ([9.2](../part-c/9-json-conventions-and-naming.md#92-camelcase-field-names)) +- [ ] Resource identifiers are opaque, server-generated strings; citizen records are never referred to by a personal identifier in a URL. ([10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)) + +## REST surfaces (OpenAPI 3.1) + +- [ ] The file declares a qualified OpenAPI 3.1 patch (`3.1.0`, `3.1.1`, or `3.1.2` for guide/ruleset `0.1.0-draft`). ([2.1](../part-a/2-openapi-document-standards.md#21-openapi-31-required)) +- [ ] The canonical entrypoint is at the default `api/openapi.yaml` path or is listed in `api/index.yaml`; no legacy `api/swagger.yaml` or JSON copy is treated as canonical. ([2.2](../part-a/2-openapi-document-standards.md#22-one-canonical-openapi-entrypoint)) +- [ ] Security schemes, parameters, headers, responses, examples, and Operation resources are local. If the four shared OpenAPI schemas are reused, the common file is vendored and version-pinned. ([2.8](../part-a/2-openapi-document-standards.md#28-conditional-vendored-openapi-schemas)) +- [ ] Every non-local server and webhook URL uses HTTPS, and OAuth declarations follow the RFC 9700 baseline: authorization code plus PKCE, no password or implicit grant, access tokens rather than ID Tokens. ([13.2](../part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations), [13.7](../part-d/13-authentication-and-authorisation.md#137-protected-transport)) +- [ ] The major version appears in the URL path (`/v{N}/...`). ([5.1](../part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path)) +- [ ] `/health` is unversioned, declares both `200` and `503`, returns `application/json` on `200`, and carries no citizen authentication. ([5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)) +- [ ] Only the standard unversioned endpoints sit outside `/v{N}/`, and none of them declares a mutating method. ([5.10](../part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints)) +- [ ] No bulk-mutating or bulk-deleting operation can run against an entire collection with no selection parameter. ([6.7](../part-b/6-http-methods.md#67-bulk-mutation-needs-explicit-selection)) +- [ ] Every operation declares every status code it can actually return; no operation declares only `200`. ([7.14](../part-b/7-http-status-codes.md#714-all-status-codes-declared)) +- [ ] Input operations declare `400`; secured operations declare applicable `401`/`403`; resource operations declare `404`; conflict-capable operations declare `409`; every operation declares `500`. Creation completed now is `201`, while accepted work is `202` plus an Operation. ([7.2](../part-b/7-http-status-codes.md#72-201-created-with-location)–[7.14](../part-b/7-http-status-codes.md#714-all-status-codes-declared)) +- [ ] Every successful response body declares a concrete media type and schema; `204` declares no content. ([7.21](../part-b/7-http-status-codes.md#721-schemas-for-successful-response-bodies)) +- [ ] Cross-service operations declare W3C `traceparent` and optional `tracestate`; error `traceId` maps to the W3C trace-id. ([8.4](../part-b/8-headers.md#84-w3c-trace-context-correlation)) +- [ ] Error responses use `application/problem+json` (RFC 9457), use `https://govstack.global/problems/{bb-code}/{problem-slug}` as the sole machine identifier, and include `traceId`. They do not add `code` or `timestamp`. ([11.1](../part-c/11-errors.md#111-rfc-9457-problem-details)–[11.3](../part-c/11-errors.md#113-trace-identifier)) +- [ ] Collection endpoints paginate, cursor pagination (`pageSize`, opaque `cursor`) is the default, and `pageInfo.nextCursor` alone indicates whether another page exists. ([12.1](../part-c/12-pagination-filtering-sorting.md#121-collections-must-paginate)–[12.3](../part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope)) +- [ ] Every `202` response returns an Operation using a local schema with an opaque string ID and documented terminal, non-terminal, result, error, polling, and cancellation semantics. ([15.1](../part-d/15-asynchronous-operations.md#151-202-with-operation-location)–[15.3](../part-d/15-asynchronous-operations.md#153-documented-operation-lifecycle)) +- [ ] Non-idempotent POST endpoints (creation, payment, submission, subscription, job start) accept an `Idempotency-Key` header. ([14.1](../part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts)) +- [ ] The major version is visible in the surface contract, and deprecated endpoints return `Deprecation` and `Sunset` headers. ([18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel), [18.5](../part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers)) + +## Event-driven surfaces (CloudEvents / AsyncAPI 3.0) + +- [ ] The file declares `asyncapi: 3.0.0` and lives at the default `api/asyncapi.yaml` path or is listed in `api/index.yaml`. ([3.1](../part-a/3-asyncapi-document-standards.md#31-asyncapi-300-required), [3.2](../part-a/3-asyncapi-document-standards.md#32-one-canonical-asyncapi-entrypoint)) +- [ ] Every domain event conforms to the CloudEvents envelope (`specversion`, `id`, `source`, `type`, domain payload under `data`). ([16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)) +- [ ] Event `type` values follow the reverse-DNS convention. ([16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types)) +- [ ] Logical channel IDs follow the versioned reverse-DNS convention, while Channel Object addresses use protocol-native syntax and bindings document the mapping. ([17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses)) +- [ ] No logical channel ID, native address, topic, queue name, routing key, or channel parameter carries personal data. ([17.3](../part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels)) +- [ ] Delivery, duplicate-handling, ordering, retention, and replay behaviour is documented only where consumers may rely on it, using standard protocol bindings where available. ([17.11](../part-d/17-asyncapi-channel-rules.md#1711-duplicate-delivery-contract)–[17.15](../part-d/17-asyncapi-channel-rules.md#1715-no-universal-delivery-extensions)) +- [ ] Add representative message examples where the schema alone does not make the interaction clear. ([17.20](../part-d/17-asyncapi-channel-rules.md#1720-representative-message-examples)) + +Ticking every box above is the human half of conformance. See [Validating your spec](../guides/validating-your-spec.md) for the mechanical half. diff --git a/api-design-guide/guides/using-with-ai-agents.md b/api-design-guide/guides/using-with-ai-agents.md new file mode 100644 index 0000000..fa16f85 --- /dev/null +++ b/api-design-guide/guides/using-with-ai-agents.md @@ -0,0 +1,36 @@ +--- +description: "How rules.yaml, the published GitBook site, and a BB repository's AGENTS.md fit together for agent-assisted spec work." +--- + +# Using this guide with AI agents + +This guide is written to be read by people and by coding agents working inside a BB repository. Three things make that practical. + +## In the repository + +`rules.yaml` at the root of this book (`api-design-guide/rules.yaml`) is the machine-readable index of every rule: its ID, enforcement class (`[M]`, `[R]`, `[M+R]`), RFC 2119 strength, applicable surface, verbatim rule text, and the page and anchor it lives on. An agent working on an OpenAPI or AsyncAPI file should treat `rules.yaml` as the lookup table, not the whole guide, and follow the page and anchor for each rule it needs to reason about, so it gets the full intent, examples, and carve-outs rather than a one-line summary. + +## On the published site + +Once this book is published as a GitBook space, the platform serves several agent-friendly surfaces automatically, with no extra authoring: an `llms.txt` and `llms-full.txt` at the site root summarising the space for LLM consumption, raw markdown for any page by appending `.md` to its URL, and a read-only MCP server at `{site-url}/~gitbook/mcp` that lets an agent query the space directly. These become available once GovStack wires the space to its final site URL; nothing about them needs to be built here. + +## Wiring a BB repository + +Paste something like this into the BB repository's `AGENTS.md` or `CLAUDE.md`: + +```markdown +This repository's API specifications must conform to the GovStack Cross-BB API Design Guide in /api-design-guide. + +Before writing or reviewing OpenAPI/AsyncAPI content: +- Read api-design-guide/rules.yaml and treat every MUST rule as blocking. +- Require each canonical spec to pin guide and ruleset version `0.1.0-draft`; do not substitute a newer version. +- Validate api/index.yaml discovery and api/coverage.yaml requirement traceability before editing an API surface. +- Cite rule IDs (for example 9.2, 11.1) when flagging or fixing violations. +- Lint the spec: `cd api-design-guide/linter && npm ci && node cli.mjs --repo-root ../..` + Every finding is prefixed with the guide rule id it enforces. Fix findings in the + spec; never edit the ruleset to silence them. +- Run the validators in api-design-guide/guides/validating-your-spec.md before declaring spec work done. +- Consult api-design-guide/guides/spec-editor-checklist.md for the review pass. + +MUST rules with enforcement class [R] cannot be machine-checked; reason about them explicitly rather than assuming a clean validator run covers them. +``` diff --git a/api-design-guide/guides/validating-your-spec.md b/api-design-guide/guides/validating-your-spec.md new file mode 100644 index 0000000..e6ce118 --- /dev/null +++ b/api-design-guide/guides/validating-your-spec.md @@ -0,0 +1,51 @@ +--- +description: "The commands to run against a BB's OpenAPI or AsyncAPI file, and an honest account of what each one does and does not catch." +--- + +# Validating your spec + +## OpenAPI + +Install the validator and run it against the canonical entrypoint: + +```bash +pip install openapi-spec-validator==0.9.0 +openapi-spec-validator api/openapi.yaml +``` + +This checks that the entrypoint is structurally valid for its declared qualified OpenAPI 3.1 patch and that its references resolve. It is the mechanical check behind [2.4](../part-a/2-openapi-document-standards.md#24-passes-openapi-spec-validator) and [20.1](../part-e/20-conformance-and-validation.md#201-every-file-passes-validation). + +## AsyncAPI + +```bash +npx @asyncapi/cli@6.0.2 validate api/asyncapi.yaml +``` + +This checks that the file is a structurally valid AsyncAPI 3.0 document. It is the mechanical check behind [3.4](../part-a/3-asyncapi-document-standards.md#34-passes-an-asyncapi-validator). + +## The GovStack Spectral ruleset + +The exact `0.1.0-draft` ruleset that encodes this guide's `[M]` rules ([20.2](../part-e/20-conformance-and-validation.md#202-passes-the-govstack-spectral-ruleset)) ships at [`linter/`](../linter/README.md). The recommended entrypoint is the driver, which discovers default canonical files or consumes `api/index.yaml`, validates `api/coverage.yaml`, runs the base validators and file-layout checks, and applies [20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) exception handling: + +```bash +cd api-design-guide/linter && npm ci +node cli.mjs --repo-root ../.. +``` + +Or run Spectral directly against the ruleset: + +```bash +npx @stoplight/spectral-cli lint -r api-design-guide/linter/ruleset.yaml api/openapi.yaml +``` + +Every finding is prefixed with the guide rule ID it enforces (for example `[7.13][M]`) and links to the rule. Each canonical file must declare `info.x-govstack-api-guide.version: 0.1.0-draft` and `rulesetVersion: 0.1.0-draft`; a missing, unavailable, or different exact version is an error, not a request to use the latest rules. An exception suppresses only the declared `rule` at or below its JSON Pointer `scope`, and only when all governance fields required by [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version) are valid and unexpired. Offline validation checks the HTTPS record URI syntax but does not dereference it. An opt-in `strict.yaml` adds noisier heuristics; [`linter/coverage.yaml`](../linter/coverage.yaml) records how each guide rule is covered. In CI, the same checks run through `linter/action.yml`. + +Running `npx @stoplight/spectral-cli lint api/openapi.yaml` *without* `-r` applies only Spectral's generic built-in rules: useful as a quick structural check, but it knows nothing about this guide. + +## The book's own machine layer + +Two scripts keep this book itself internally consistent rather than checking a BB's spec: `python3 tools/check_links.py` and `python3 tools/build_rules_index.py --check`. See [Maintaining this guide](../guides/maintaining-this-guide.md) for when and why to run them. + +## What "passing" actually means + +Each rule in this guide carries an enforcement-class tag explained in [§1.9](../1-introduction.md#19-rule-enforcement-classes): `[M]` (machine-checkable), `[R]` (requires human review), or `[M+R]` (a linter can check the structural part, a reviewer must confirm the semantic part). The validators above, and the GovStack Spectral ruleset, cover the `[M]` rules and the mechanical half of the `[M+R]` rules (to the extent recorded in [`linter/coverage.yaml`](../linter/coverage.yaml)). Everything else, including every `[R]` rule and the judgement half of every `[M+R]` rule, is verified through specification review, not a command line. A clean validator run is necessary for conformance; it is not sufficient. diff --git a/api-design-guide/how-to-use-this-guide.md b/api-design-guide/how-to-use-this-guide.md new file mode 100644 index 0000000..ebfa840 --- /dev/null +++ b/api-design-guide/how-to-use-this-guide.md @@ -0,0 +1,29 @@ +--- +description: "Entry points by audience and guidance for reviewing the draft." +--- + +# How to use this guide + +The guide is written for lookup, not end-to-end reading ([§1.4](1-introduction.md#14-audience)). Every rule has its own heading and a stable deep link, an identifier like **9.2**, an enforcement badge (**[M]** machine-checkable, **[R]** review, **[M+R]** both; see [§1.9](1-introduction.md#19-rule-enforcement-classes)), and RFC 2119 keywords in the body. + +## Find your path + +- **BB spec editors** (primary audience): start from the [spec editor checklist](guides/spec-editor-checklist.md), jump to rules via [Rules at a glance](all-rules.md), and run the commands in [Validating your spec](guides/validating-your-spec.md) before submitting for review. +- **Implementers** building against a BB spec: read [§1.8](1-introduction.md#18-layering-what-this-guide-constrains) first to see which promises live in the spec, the implementation profile, or the deployment; then the sections for the surfaces you consume (errors [§11](part-c/11-errors.md), pagination [§12](part-c/12-pagination-filtering-sorting.md), idempotency [§14](part-d/14-idempotency.md), events [§16](part-d/16-cloudevents-and-webhooks.md)). +- **Country teams** adopting BBs: the [Introduction](1-introduction.md) and [§18 Compatibility and lifecycle](part-d/18-compatibility-and-lifecycle.md) describe what consistency you can rely on across the catalogue; concrete deployment values live in implementation profiles, not here. +- **AI coding agents and tooling**: `rules.yaml` at the folder root is the machine-readable index of every rule; [Using this guide with AI agents](guides/using-with-ai-agents.md) explains it and provides a paste-ready instruction block. + +## How to review this draft + +This is a strawman of a normative cross-BB API design guide. It is not yet ratified. At this stage, committee feedback should focus on: + +1. **Scope of harmonisation** (see [§1.2](1-introduction.md#12-scope)). +2. **Structure and depth.** 20 numbered sections ([§1](1-introduction.md)–[§20](part-e/20-conformance-and-validation.md)), supported by examples and rule-specific linter coverage where mechanical enforcement is practical. +3. **Prescriptiveness.** RFC 2119 keywords and linter-backed conformance (see [§1.5](1-introduction.md#15-language) and [§20](part-e/20-conformance-and-validation.md)). +4. **CFR compatibility.** Confirm that the proposed parent relationships and pending CFR changes in [§1.3](1-introduction.md#13-relationship-to-existing-govstack-documents) are correct. + +Review comments should challenge the draft's proposed rules directly through issues or pull-request feedback rather than relying on embedded drafting questions. + +## About the rule titles + +The short titles on rule headings (for example "9.2 camelCase field names") are navigation aids. They are **non-normative**: only the rule body defines the requirement. If a title and its body ever seem to disagree, the body wins, and please report it. diff --git a/api-design-guide/linter/.gitignore b/api-design-guide/linter/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/api-design-guide/linter/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/api-design-guide/linter/README.md b/api-design-guide/linter/README.md new file mode 100644 index 0000000..4307496 --- /dev/null +++ b/api-design-guide/linter/README.md @@ -0,0 +1,177 @@ +# GovStack API lint + +The GovStack Spectral ruleset and lint tooling for the +[Cross-BB API Design Guide](../README.md). This is the mechanical enforcement +behind draft rule [20.2](../part-e/20-conformance-and-validation.md). It implements guide +version **0.1.0-draft** (`guide_version` in [coverage.yaml](coverage.yaml)). + +## Quick start + +```bash +cd api-design-guide/linter +npm ci +node cli.mjs --repo-root ../.. # follows the repository's api/index.yaml declaration +``` + +For multiple API surfaces, declare every document explicitly in +`api/index.yaml`: + +```yaml +version: 1 +apis: + - type: openapi + path: api/public-openapi.yaml + - type: asyncapi + path: api/events-asyncapi.yaml + - type: standard + name: OpenID Connect + reference: https://openid.net/specs/openid-connect-core-1_0.html + discovery: /.well-known/openid-configuration +``` + +The `standard` form is provisional. It is useful in advisory mode, but blocks +conformance until GovStack approves a recognised-standard registry or profile; +an HTTPS reference alone is not treated as conformance evidence. + +A BB with no API surface must say so explicitly instead of keeping empty spec +placeholders: + +```yaml +version: 1 +noApi: true +reason: This BB publishes reusable components only. +``` + +The driver runs everything rule 20 asks for: + +1. **File-tree checks** — `api/index.yaml` or canonical entrypoints, + declaration/type consistency, legacy `swagger.*` names, and undeclared or + divergent spec copies (guide 2.2/2.3/3.2/3.3). +2. **Requirement coverage** — every active CFR-formatted REQUIRED or + RECOMMENDED requirement under `spec/**/*.md` must have one disposition in + `api/coverage.yaml`. DRAFT, DEPRECATED, and INAPPLICABLE requirements are + not active coverage obligations. Mapped operation/message identifiers must + exist and be unambiguous. +3. **Base validators** (20.1) — `openapi-spec-validator` and + `@asyncapi/cli validate` are mandatory in conformance mode. +4. **The Spectral ruleset** — rules across both surfaces (OpenAPI 3.1, + AsyncAPI 3.0). Spectral auto-detects the document type. +5. **Declared exceptions** (20.3) — approved, unexpired exceptions suppress a + matching rule only at or below their RFC 6901 JSON Pointer scope. Driver + findings cannot be suppressed. + +Flags: `--openapi `, `--asyncapi `, `--ruleset `, `--strict`, +`--mode conformance|advisory`, `--fail-on error|warn|info|never` (default +`error`), `--format text|json`, and `--skip-validators`. Conformance mode is +the default and rejects missing or skipped base validators. Advisory mode can +use `--skip-validators` for local ruleset review. Exit codes: `0` clean or +below threshold, `1` findings at or above `--fail-on`, `2` operational error. + +Every spec must declare the exact guide and ruleset version. Exceptions use +the following shape. `record` must be HTTPS, dates use `YYYY-MM-DD`, and +`scope` must be an RFC 6901 JSON Pointer (the empty string means the root). + +```yaml +info: + x-govstack-api-guide: + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft + exceptions: + - rule: "9.5" + scope: /components/schemas/LegacyRecord + rationale: Existing clients depend on this wire name. + record: https://example.gov/decisions/API-42 + reviewedBy: GovStack API review group + reviewedAt: "2026-07-01" + expiresAt: "2026-10-01" +``` + +You can also run Spectral directly: + +```bash +npx spectral lint -r ruleset.yaml api/openapi.yaml +npx spectral lint -r strict.yaml api/openapi.yaml # opt-in noisy heuristics +``` + +## Rule naming and severities + +Spectral rules are named `govstack-` (suffixed when one guide +rule needs several checks, e.g. `govstack-5.9-status-enum`). Every message +starts with `[][]` and every rule carries a `documentationUrl` +pointing at the rule's section of the guide. + +Severity policy (mechanical, no judgment): + +- Fully checkable rules inherit their RFC 2119 strength: MUST → `error`, + SHOULD → `warn`, MAY → `info`. +- Proxy checks (the linter verifies an automatable stand-in, not the whole + rule) run one notch lower, so an imperfect heuristic never blocks at + `error`. Each fragment comments on what its proxies do *not* verify. +- Noisy heuristics (pluralization, verb detection, personal-data term scans, + copy-paste detection) ship only in `strict.yaml` (8 rules, opt-in, `warn`). + +Two deliberate overlaps to be aware of: `govstack-8.3` and `govstack-14.1` +both require an `Idempotency-Key` header on creating POSTs (each cites its own +guide rule), and the §10 field-format rules can report a `$ref`'d schema once +per reference path. Fixing the schema clears all copies. + +## Coverage + +[coverage.yaml](coverage.yaml) maps every guide rule to its +enforcement status — it is the scope contract, machine-checked by +`tests/coverage.test.mjs` in both directions: + +| status | meaning | +| --- | --- | +| `implemented` | fully checked by the listed Spectral rules | +| `partial-proxy` | an automated proxy is checked; the note says what is not | +| `driver` | checked by `cli.mjs`, not Spectral | +| `strict-only` | noisy heuristic, ships only in `strict.yaml` | +| `needs-context` | needs an external registry, common artifact, or comparison input | +| `runtime` | constrains wire behaviour; test-harness territory | +| `human` | review or governance judgment | +| `informative` | non-normative guide entry | + +Cross-version breaking-change rules (18.3/18.4) are diff territory, not lint +territory: run [oasdiff](https://github.com/oasdiff/oasdiff) against the +previously published OpenAPI version. + +## Reference examples + +`tests/golden/openapi-golden.yaml` and `tests/golden/asyncapi-golden.yaml` are +complete specs that pass the entire default ruleset. When a rule's requirement +is unclear, they show a shape that satisfies it. + +## CI + +`action.yml` is a composite GitHub Action wrapping the driver. Minimal usage +in a BB repo: + +```yaml +- uses: actions/checkout@v4 +- uses: ./api-design-guide/linter + with: + fail-on: error +``` + +The template's own workflow (`.github/workflows/api-spec-lint.yml`) runs both +the linter's test suite and the repository conformance check. Empty legacy +placeholders are not conformant. + +## Development + +```bash +npm test # fixtures, functions, driver, coverage, golden, and harness +``` + +Every Spectral rule has `tests/fixtures//{fail,pass}.yaml`: the +runner lints both with the full bundled ruleset and asserts the rule fires on +`fail.yaml` and not on `pass.yaml`. When a guide rule changes, update the rule, +its fixtures, and its `coverage.yaml` entry together — the drift checks fail +otherwise. Shared custom functions live in `functions/` (API reference in +[functions/README.md](functions/README.md)); section-specific ones are +prefixed `sNN-`. + +The ruleset is plain Spectral format, so it also runs under +[vacuum](https://github.com/daveshanley/vacuum) (v0.29+) for a faster CI +alternative; the driver only shells out to Spectral. diff --git a/api-design-guide/linter/action.yml b/api-design-guide/linter/action.yml new file mode 100644 index 0000000..30eba61 --- /dev/null +++ b/api-design-guide/linter/action.yml @@ -0,0 +1,97 @@ +# GovStack API spec lint — composite action +# +# Lints a Building Block's OpenAPI/AsyncAPI spec against the GovStack Cross-BB +# API Design Guide Spectral ruleset using the Node driver in this directory +# (cli.mjs). See ../guides/validating-your-spec.md for the ruleset itself. +# +# This action assumes the calling workflow has ALREADY checked out the +# repository (e.g. via actions/checkout) before this action runs — it does +# not check out anything itself. +name: "GovStack API spec lint" +description: "Lints a BB's OpenAPI/AsyncAPI spec against the GovStack Cross-BB API Design Guide ruleset." + +inputs: + openapi-path: + description: "Optional explicit OpenAPI path. Leave empty to use api/index.yaml or canonical discovery." + required: false + default: "" + asyncapi-path: + description: "Optional explicit AsyncAPI path. Leave empty to use api/index.yaml or canonical discovery." + required: false + default: "" + fail-on: + description: "Minimum severity that fails the check: error, warn, info, or never." + required: false + default: "error" + strict: + description: "Set to 'true' to also lint against the guide's strict ruleset." + required: false + default: "false" + mode: + description: "conformance blocks missing validators and contract gaps; advisory permits validator-free local review." + required: false + default: "conformance" + node-version: + description: "Node.js version to set up for running the linter." + required: false + default: "22" + install-validators: + description: >- + Set to 'true' to install openapi-spec-validator (Python) and + @asyncapi/cli (npm) so the driver can additionally run schema + validation. Set to 'false' only with mode=advisory; conformance rejects + skipped or unavailable validators. + required: false + default: "true" + +runs: + using: "composite" + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + + # openapi-spec-validator is a Python tool the driver shells out to. + - name: Set up Python (for openapi-spec-validator) + if: inputs.install-validators == 'true' + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install openapi-spec-validator + if: inputs.install-validators == 'true' + shell: bash + run: pip install openapi-spec-validator==0.9.0 + + - name: Install @asyncapi/cli + if: inputs.install-validators == 'true' + shell: bash + run: npm i -g @asyncapi/cli@6.0.2 + + - name: Install linter dependencies + shell: bash + working-directory: ${{ github.action_path }} + run: npm ci + + - name: Run GovStack API spec lint + shell: bash + run: | + args=( + --repo-root "$GITHUB_WORKSPACE" + --mode "${{ inputs.mode }}" + --fail-on "${{ inputs.fail-on }}" + ) + if [[ -n "${{ inputs.openapi-path }}" ]]; then + args+=(--openapi "${{ inputs.openapi-path }}") + fi + if [[ -n "${{ inputs.asyncapi-path }}" ]]; then + args+=(--asyncapi "${{ inputs.asyncapi-path }}") + fi + if [[ "${{ inputs.strict }}" == "true" ]]; then + args+=(--strict) + fi + if [[ "${{ inputs.install-validators }}" == "false" ]]; then + args+=(--skip-validators) + fi + node "${{ github.action_path }}/cli.mjs" "${args[@]}" diff --git a/api-design-guide/linter/cli.mjs b/api-design-guide/linter/cli.mjs new file mode 100755 index 0000000..28a8915 --- /dev/null +++ b/api-design-guide/linter/cli.mjs @@ -0,0 +1,1509 @@ +#!/usr/bin/env node +// GovStack API lint driver. +// +// Orchestrates three layers of conformance checking for a Building Block repo: +// 1. File-tree checks the Spectral ruleset cannot see (guide §2.2/§2.3/§3.2/§3.3): +// canonical entrypoint location, legacy swagger.* files, divergent spec copies. +// 2. Base validators (§20.1): openapi-spec-validator and the AsyncAPI CLI. +// 3. The GovStack Spectral ruleset (§20.2), run programmatically. +// +// It also consumes each spec's info.x-govstack-api-guide declaration (§20.3) to compare +// the targeted guide version and to suppress findings covered by approved exceptions. +// +// The ruleset itself (ruleset.yaml / strict.yaml / rulesets/* / functions/*) is authored +// elsewhere; this driver only loads and runs it. + +import { parseArgs } from 'node:util'; +import * as fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawn } from 'node:child_process'; +import YAML from 'yaml'; +import spectralCore from '@stoplight/spectral-core'; +import Parsers from '@stoplight/spectral-parsers'; +import bundler from '@stoplight/spectral-ruleset-bundler/with-loader'; + +const { Spectral, Document } = spectralCore; +const { bundleAndLoadRuleset } = bundler; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SUPPORTED_GUIDE_VERSION = '0.1.0-draft'; +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +// Spectral severity numbers -> our names. 0=error 1=warn 2=info 3=hint. +const SEVERITY_NAME = ['error', 'warn', 'info', 'hint']; +const SEVERITY_NUM = { error: 0, warn: 1, info: 2, hint: 3 }; +// --fail-on threshold: a finding fails the run when its severity number <= threshold. +const FAIL_ON_THRESHOLD = { error: 0, warn: 1, info: 2, never: -1 }; +const MODES = new Set(['conformance', 'advisory']); + +// Directories never scanned for divergent spec copies (§2.3/§3.3). +const DIVERGENT_EXCLUDE_DIRS = new Set([ + '.git', + 'node_modules', + 'api-design-guide', + 'test', + 'examples', +]); + +// Operational failures (bad flags, unreadable/unparseable spec, ruleset load failure) -> exit 2. +class OperationalError extends Error {} + +// -------------------------------------------------------------------------------------- +// Argument parsing +// -------------------------------------------------------------------------------------- + +function parseCliArgs(argv) { + let values; + try { + ({ values } = parseArgs({ + args: argv, + options: { + 'repo-root': { type: 'string' }, + openapi: { type: 'string' }, + asyncapi: { type: 'string' }, + ruleset: { type: 'string' }, + strict: { type: 'boolean', default: false }, + 'fail-on': { type: 'string', default: 'error' }, + format: { type: 'string', default: 'text' }, + mode: { type: 'string', default: 'conformance' }, + 'skip-validators': { type: 'boolean', default: false }, + }, + allowPositionals: false, + strict: true, + })); + } catch (err) { + throw new OperationalError(`Invalid arguments: ${err.message}`); + } + + const failOn = values['fail-on']; + if (!Object.prototype.hasOwnProperty.call(FAIL_ON_THRESHOLD, failOn)) { + throw new OperationalError( + `Invalid --fail-on value "${failOn}" (expected error|warn|info|never).`, + ); + } + const format = values.format; + if (format !== 'text' && format !== 'json') { + throw new OperationalError(`Invalid --format value "${format}" (expected text|json).`); + } + if (!MODES.has(values.mode)) { + throw new OperationalError( + `Invalid --mode value "${values.mode}" (expected conformance|advisory).`, + ); + } + if (values.mode === 'conformance' && values['skip-validators']) { + throw new OperationalError( + '--skip-validators is only available with --mode advisory; conformance requires base validators.', + ); + } + return values; +} + +// -------------------------------------------------------------------------------------- +// Path resolution +// -------------------------------------------------------------------------------------- + +// Nearest ancestor of `start` (inclusive) containing a .git entry, else `start`. +function findRepoRoot(start) { + let dir = path.resolve(start); + for (;;) { + if (fs.existsSync(path.join(dir, '.git'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) return path.resolve(start); + dir = parent; + } +} + +function resolveConfig(values) { + const repoRoot = values['repo-root'] + ? path.resolve(values['repo-root']) + : findRepoRoot(process.cwd()); + + const openapiPath = values.openapi ? path.resolve(repoRoot, values.openapi) : null; + const asyncapiPath = values.asyncapi ? path.resolve(repoRoot, values.asyncapi) : null; + + let rulesetPath; + if (values.ruleset) { + rulesetPath = path.resolve(values.ruleset); + } else { + rulesetPath = path.join(HERE, values.strict ? 'strict.yaml' : 'ruleset.yaml'); + } + + return { + repoRoot, + openapiPath, + asyncapiPath, + rulesetPath, + failOn: values['fail-on'], + format: values.format, + mode: values.mode, + skipValidators: values['skip-validators'], + }; +} + +// -------------------------------------------------------------------------------------- +// Spec file loading +// -------------------------------------------------------------------------------------- + +// Returns { present, empty, content, data }. `present` means the file exists and holds +// more than whitespace. An unreadable-but-existing file, or unparseable YAML, is fatal. +async function loadSpec(absPath, relDisplay) { + let content; + try { + content = await fsp.readFile(absPath, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') return { present: false, empty: false }; + throw new OperationalError(`Cannot read spec file ${relDisplay}: ${err.message}`); + } + if (content.trim() === '') return { present: false, empty: true }; + + let data; + try { + data = YAML.parse(content); + } catch (err) { + throw new OperationalError(`Cannot parse spec file ${relDisplay}: ${err.message}`); + } + return { present: true, empty: false, content, data }; +} + +function driverFinding(file, code, message, { guideRule = null, severity = 'error' } = {}) { + return { + file, + code, + guideRule, + severity, + message, + jsonPath: [], + range: null, + documentationUrl: null, + }; +} + +async function readOptionalText(absPath) { + try { + return { exists: true, content: await fsp.readFile(absPath, 'utf8') }; + } catch (err) { + if (err.code === 'ENOENT') return { exists: false, content: '' }; + throw new OperationalError(`Cannot read ${absPath}: ${err.message}`); + } +} + +function isInside(parent, candidate) { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative); +} + +function parseYamlObject(content, displayPath) { + try { + const value = YAML.parse(content); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('document root must be an object'); + } + return value; + } catch (err) { + throw new OperationalError(`Cannot parse ${displayPath}: ${err.message}`); + } +} + +function validateIndexDocument(index, indexRel, repoRoot, findings) { + const declarations = []; + const standardSurfaces = []; + let noApi = false; + + if (index.version !== 1) { + findings.push(driverFinding(indexRel, 'api-index-invalid', 'api/index.yaml must declare version: 1.')); + } + + const hasApis = Object.prototype.hasOwnProperty.call(index, 'apis'); + const hasNoApi = Object.prototype.hasOwnProperty.call(index, 'noApi'); + if (hasApis === hasNoApi) { + findings.push( + driverFinding( + indexRel, + 'api-index-invalid', + 'api/index.yaml must declare exactly one of a non-empty apis list or noApi: true.', + ), + ); + return { declarations, standardSurfaces, noApi }; + } + + if (hasNoApi) { + if (index.noApi !== true) { + findings.push(driverFinding(indexRel, 'api-index-invalid', 'noApi must be true when declared.')); + return { declarations, standardSurfaces, noApi }; + } + noApi = true; + if (typeof index.reason !== 'string' || index.reason.trim().length < 3) { + findings.push( + driverFinding(indexRel, 'api-index-invalid', 'noApi: true requires a non-empty reason.'), + ); + } + return { declarations, standardSurfaces, noApi }; + } + + if (!Array.isArray(index.apis) || index.apis.length === 0) { + findings.push(driverFinding(indexRel, 'api-index-invalid', 'apis must be a non-empty array.')); + return { declarations, standardSurfaces, noApi }; + } + + const seen = new Set(); + for (const [i, entry] of index.apis.entries()) { + const itemPath = `${indexRel}#apis[${i}]`; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath} must be an object.`)); + continue; + } + if (entry.type !== 'openapi' && entry.type !== 'asyncapi' && entry.type !== 'standard') { + findings.push( + driverFinding(indexRel, 'api-index-invalid', `${itemPath}.type must be openapi, asyncapi, or standard.`), + ); + continue; + } + if (entry.type === 'standard') { + if (typeof entry.name !== 'string' || !entry.name.trim()) { + findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath}.name is required.`)); + continue; + } + if (!isHttpsUrl(entry.reference)) { + findings.push( + driverFinding(indexRel, 'api-index-invalid', `${itemPath}.reference must be an absolute HTTPS URL.`), + ); + continue; + } + if (entry.discovery !== undefined && (typeof entry.discovery !== 'string' || !entry.discovery.trim())) { + findings.push( + driverFinding(indexRel, 'api-index-invalid', `${itemPath}.discovery must be a non-empty string when present.`), + ); + continue; + } + const key = `standard:${entry.name.trim()}:${entry.reference}`; + if (seen.has(key)) { + findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath} duplicates a standard surface.`)); + continue; + } + seen.add(key); + standardSurfaces.push({ + kind: 'standard', + name: entry.name.trim(), + reference: entry.reference, + discovery: entry.discovery?.trim() ?? null, + declaredBy: indexRel, + }); + continue; + } + if (typeof entry.path !== 'string' || !entry.path.trim()) { + findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath}.path is required.`)); + continue; + } + const normalized = entry.path.replaceAll('\\', '/').replace(/^\.\//, ''); + const abs = path.resolve(repoRoot, normalized); + if (!isInside(path.join(repoRoot, 'api'), abs) || !/\.ya?ml$/i.test(normalized)) { + findings.push( + driverFinding( + indexRel, + 'api-index-invalid', + `${itemPath}.path must be a repo-relative YAML path inside api/.`, + ), + ); + continue; + } + if (seen.has(abs)) { + findings.push(driverFinding(indexRel, 'api-index-invalid', `${itemPath}.path is duplicated.`)); + continue; + } + seen.add(abs); + declarations.push({ kind: entry.type, abs, declaredBy: indexRel }); + } + return { declarations, standardSurfaces, noApi }; +} + +async function discoverApiDeclarations(cfg, rel, findings, notices) { + const explicit = []; + if (cfg.openapiPath) explicit.push({ kind: 'openapi', abs: cfg.openapiPath, declaredBy: 'CLI' }); + if (cfg.asyncapiPath) explicit.push({ kind: 'asyncapi', abs: cfg.asyncapiPath, declaredBy: 'CLI' }); + if (explicit.length) { + return { declarations: explicit, standardSurfaces: [], noApi: false, indexPresent: false }; + } + + const indexAbs = path.join(cfg.repoRoot, 'api', 'index.yaml'); + const indexRel = rel(indexAbs); + const indexFile = await readOptionalText(indexAbs); + if (indexFile.exists) { + if (!indexFile.content.trim()) { + findings.push(driverFinding(indexRel, 'api-index-invalid', 'api/index.yaml must not be empty.')); + return { declarations: [], standardSurfaces: [], noApi: false, indexPresent: true }; + } + const index = parseYamlObject(indexFile.content, indexRel); + return { ...validateIndexDocument(index, indexRel, cfg.repoRoot, findings), indexPresent: true }; + } + + const declarations = []; + for (const [kind, name] of [ + ['openapi', 'openapi.yaml'], + ['asyncapi', 'asyncapi.yaml'], + ]) { + const abs = path.join(cfg.repoRoot, 'api', name); + const candidate = await readOptionalText(abs); + if (candidate.exists) declarations.push({ kind, abs, declaredBy: 'canonical-path' }); + } + + if (declarations.length === 0) { + const message = + 'No API declaration found. Add api/openapi.yaml or api/asyncapi.yaml, declare a standard-defined surface in api/index.yaml, or declare noApi: true with a reason.'; + if (cfg.mode === 'conformance') { + findings.push(driverFinding('api/index.yaml', 'api-declaration-required', message)); + } else { + notices.push(message); + } + } + return { declarations, standardSurfaces: [], noApi: false, indexPresent: false }; +} + +async function loadDeclaredSpecs(declarations, rel, findings) { + const specs = []; + for (const declaration of declarations) { + const relSpec = rel(declaration.abs); + const loaded = await loadSpec(declaration.abs, relSpec); + if (!loaded.present) { + findings.push( + driverFinding( + relSpec, + loaded.empty ? 'declared-spec-empty' : 'declared-spec-missing', + `Declared ${declaration.kind} specification ${relSpec} ${loaded.empty ? 'is empty' : 'does not exist'}.`, + { guideRule: declaration.kind === 'openapi' ? '2.2' : '3.2' }, + ), + ); + continue; + } + const actualKind = + typeof loaded.data?.openapi === 'string' + ? 'openapi' + : typeof loaded.data?.asyncapi === 'string' + ? 'asyncapi' + : null; + if (actualKind !== declaration.kind) { + findings.push( + driverFinding( + relSpec, + 'declared-spec-type', + `Declared ${declaration.kind} specification ${relSpec} does not contain a matching root version field.`, + { guideRule: declaration.kind === 'openapi' ? '2.1' : '3.1' }, + ), + ); + continue; + } + specs.push({ kind: declaration.kind, abs: declaration.abs, ...loaded }); + } + return specs; +} + +// -------------------------------------------------------------------------------------- +// File-tree checks (§2.2 / §2.3 / §3.2 / §3.3) +// -------------------------------------------------------------------------------------- + +// Legacy api/swagger.{yaml,json} artifacts are always non-conformant, including +// empty placeholders. An explicit no-API declaration lives in api/index.yaml. +async function checkLegacySwagger(repoRoot, rel, findings) { + let anyPresent = false; + for (const name of ['swagger.yaml', 'swagger.json']) { + const abs = path.join(repoRoot, 'api', name); + let content; + try { + content = await fsp.readFile(abs, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') continue; + throw new OperationalError(`Cannot read ${rel(abs)}: ${err.message}`); + } + anyPresent = true; + const qualifier = content.trim() === '' ? 'Empty legacy placeholder' : 'Legacy API specification'; + findings.push( + driverFinding( + rel(abs), + 'file-canonical-name', + `${qualifier} ${rel(abs)} must be removed or migrated to a declared YAML entrypoint under api/ (guide §2.2).`, + { guideRule: '2.2' }, + ), + ); + } + return anyPresent; +} + +async function readHead(absPath, n) { + const fh = await fsp.open(absPath, 'r'); + try { + const buf = Buffer.alloc(n); + const { bytesRead } = await fh.read(buf, 0, n, 0); + return buf.subarray(0, bytesRead).toString('utf8'); + } finally { + await fh.close(); + } +} + +const SNIFF_RE = /(?:^|[\s"'{,])(openapi|asyncapi)["']?\s*:/im; +const SNIFF_BYTES = 2048; +const FULL_PARSE_MAX_BYTES = 5 * 1024 * 1024; + +// Cheap top-of-file sniff, then confirm by parse (only if not huge). Returns +// { kind } when the file looks like a top-level OpenAPI/AsyncAPI document, else null. +async function sniffSpec(absPath) { + let head; + try { + head = await readHead(absPath, SNIFF_BYTES); + } catch { + return null; + } + if (!SNIFF_RE.test(head)) return null; + + let size = 0; + try { + size = (await fsp.stat(absPath)).size; + } catch { + return null; + } + if (size > FULL_PARSE_MAX_BYTES) { + // Too large to parse cheaply; trust the sniff (heuristic). + return { kind: 'API', heuristic: true }; + } + let data; + try { + data = YAML.parse(await fsp.readFile(absPath, 'utf8')); + } catch { + return null; + } + if (data && typeof data === 'object' && !Array.isArray(data)) { + const hasEntries = (value) => + value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0; + const hasComponents = hasEntries(data.components); + if (typeof data.openapi === 'string') { + return { + kind: 'OpenAPI', + heuristic: false, + supportOnly: hasComponents && !hasEntries(data.paths) && !hasEntries(data.webhooks), + }; + } + if (typeof data.asyncapi === 'string') { + return { + kind: 'AsyncAPI', + heuristic: false, + supportOnly: hasComponents && !hasEntries(data.channels) && !hasEntries(data.operations), + }; + } + } + return null; +} + +// Walk the repo for undeclared top-level specs, including api/ and spec/ assets. +// Parsed documents are deterministic errors; only oversized sniff-only hits stay advisory. +async function scanDivergentCopies(repoRoot, skipAbs, rel, findings) { + const commonRoot = path.resolve(repoRoot, 'api', 'common'); + + async function walk(dir) { + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (DIVERGENT_EXCLUDE_DIRS.has(entry.name)) continue; + await walk(full); + } else if (entry.isFile()) { + if (!/\.(ya?ml|json)$/i.test(entry.name)) continue; + if (skipAbs.has(path.resolve(full))) continue; + const hit = await sniffSpec(full); + if (hit) { + const insideCommonRoot = path.resolve(full).startsWith(`${commonRoot}${path.sep}`); + if (insideCommonRoot && hit.supportOnly) continue; + const guideRule = hit.kind === 'AsyncAPI' ? '3.3' : '2.3'; + findings.push( + driverFinding( + rel(full), + 'file-undeclared-spec', + `${hit.heuristic ? 'Heuristic: ' : ''}${rel(full)} is an undeclared ${hit.kind} document. ` + + `Every top-level API specification must be canonical or listed in api/index.yaml (guide §${guideRule}).`, + { guideRule, severity: hit.heuristic ? 'warn' : 'error' }, + ), + ); + } + } + } + } + + await walk(repoRoot); +} + +// -------------------------------------------------------------------------------------- +// Requirement-to-contract coverage (api/coverage.yaml) +// -------------------------------------------------------------------------------------- + +const REQUIREMENT_ID_RE = /^govstack-[a-z0-9]+(?:[-.][a-z0-9]+)*#req-[1-9][0-9]*$/; +const DISPOSITIONS = new Set(['operation', 'message', 'external', 'non-api', 'planned']); +const REQUIREMENT_HEADING_RE = + /^\s*###\s+#([1-9][0-9]*)\s+(.+?)\s+\((REQUIRED|RECOMMENDED|DRAFT|DEPRECATED)\s+(IMMUTABLE|EXTENSIBLE|REPLACEABLE|INAPPLICABLE)\s+(OBSERVABLE|AUDITABLE)\)\s*$/; +const REQUIREMENT_REFERENCE_RE = + /^\s*`(govstack-[a-z0-9]+(?:[-.][a-z0-9]+)*#req-([1-9][0-9]*))(?:\s+(extends|replaces)\s+(govstack-[a-z0-9]+(?:[-.][a-z0-9]+)*#req-[1-9][0-9]*))?`\s*$/; +const LEGACY_REQUIREMENT_RE = + /^\s*-\s+\*\*[^*]+\*\*\s+\*\*(REQUIRED|RECOMMENDED|OPTIONAL|DRAFT|DEPRECATED)\*\*:/; +const LOOKS_LIKE_REQUIREMENT_HEADING_RE = /^\s*#+\s+#\d+\s+/; + +function nonEmptyStrings(value) { + return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item === 'string' && item.trim()); +} + +function isHttpUrl(value) { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +function isHttpsUrl(value) { + if (typeof value !== 'string') return false; + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +async function scanRequirementMarkers(repoRoot, rel, findings) { + const specDir = path.join(repoRoot, 'spec'); + const markers = new Map(); + const seenIds = new Map(); + + async function walk(dir) { + let entries; + try { + entries = await fsp.readdir(dir, { withFileTypes: true }); + } catch (err) { + if (err.code === 'ENOENT') return; + throw new OperationalError(`Cannot scan requirement sources under ${rel(dir)}: ${err.message}`); + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(full); + continue; + } + if (!entry.isFile() || !/\.md$/i.test(entry.name)) continue; + const lines = (await fsp.readFile(full, 'utf8')).split(/\r?\n/); + let fenced = false; + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + if (/^\s*```/.test(line)) { + fenced = !fenced; + continue; + } + if (fenced) continue; + const match = line.match(REQUIREMENT_HEADING_RE); + if (match) { + const [, headingNumber, title, level, mutability, verification] = match; + const headingLocation = `${rel(full)}:${i + 1}`; + let referenceLine = i + 1; + while (referenceLine < lines.length && !lines[referenceLine].trim()) referenceLine += 1; + const reference = lines[referenceLine]?.match(REQUIREMENT_REFERENCE_RE); + if (!reference) { + findings.push( + driverFinding( + rel(full), + 'requirements-invalid-marker', + `Requirement at ${headingLocation} must be followed by a canonical ` + + '`govstack-...#req-N` identifier, optionally with extends or replaces.', + ), + ); + continue; + } + const [, id, referenceNumber, relation, parent] = reference; + const referenceLocation = `${rel(full)}:${referenceLine + 1}`; + let bodyLine = referenceLine + 1; + while (bodyLine < lines.length && !lines[bodyLine].trim()) bodyLine += 1; + while (/^KF:\s+\S/.test(lines[bodyLine]?.trim() ?? '')) { + bodyLine += 1; + while (bodyLine < lines.length && !lines[bodyLine].trim()) bodyLine += 1; + } + const body = lines[bodyLine]?.trim() ?? ''; + if (!body || /^#{1,6}\s+/.test(body)) { + findings.push( + driverFinding( + rel(full), + 'requirements-invalid-marker', + `Requirement ${id} at ${headingLocation} must include body text after its canonical identifier and optional KF lines.`, + ), + ); + i = referenceLine; + continue; + } + if (headingNumber !== referenceNumber) { + findings.push( + driverFinding( + rel(full), + 'requirements-invalid-marker', + `Requirement number #${headingNumber} at ${headingLocation} does not match ${id} at ${referenceLocation}.`, + ), + ); + } + if (mutability === 'INAPPLICABLE' && !relation) { + findings.push( + driverFinding( + rel(full), + 'requirements-invalid-marker', + `INAPPLICABLE requirement ${id} must identify a parent requirement with extends or replaces.`, + ), + ); + } + if (seenIds.has(id)) { + findings.push( + driverFinding( + rel(full), + 'requirements-duplicate-id', + `Requirement id "${id}" is duplicated at ${seenIds.get(id)} and ${referenceLocation}.`, + ), + ); + } else { + seenIds.set(id, referenceLocation); + const active = (level === 'REQUIRED' || level === 'RECOMMENDED') && mutability !== 'INAPPLICABLE'; + if (active) { + markers.set(id, { + id, + title: title.trim(), + level, + mutability, + verification, + relation: relation ?? null, + parent: parent ?? null, + location: headingLocation, + }); + } + } + i = referenceLine; + continue; + } + if (LEGACY_REQUIREMENT_RE.test(line)) { + findings.push( + driverFinding( + rel(full), + 'requirements-unkeyed', + `Legacy requirement marker at ${rel(full)}:${i + 1}; use the GovStack requirement heading, classifiers, and canonical identifier.`, + ), + ); + } else if (LOOKS_LIKE_REQUIREMENT_HEADING_RE.test(line)) { + findings.push( + driverFinding( + rel(full), + 'requirements-invalid-marker', + `Invalid GovStack requirement heading at ${rel(full)}:${i + 1}; expected all three CFR classifiers.`, + ), + ); + } + } + } + } + + await walk(specDir); + return markers; +} + +function collectReferenceInventory(specs, rel, findings, coverageRel) { + const operationOwners = new Map(); + const messageOwners = new Map(); + + const addOwner = (map, id, owner) => { + if (typeof id !== 'string' || !id.trim()) return; + const owners = map.get(id) ?? new Set(); + owners.add(owner); + map.set(id, owners); + }; + + for (const spec of specs) { + const owner = rel(spec.abs); + if (spec.kind === 'openapi') { + const paths = spec.data?.paths; + if (paths && typeof paths === 'object') { + for (const item of Object.values(paths)) { + if (!item || typeof item !== 'object') continue; + for (const method of HTTP_METHODS) addOwner(operationOwners, item[method]?.operationId, owner); + } + } + const webhooks = spec.data?.webhooks; + if (webhooks && typeof webhooks === 'object') { + for (const item of Object.values(webhooks)) { + if (!item || typeof item !== 'object') continue; + for (const method of HTTP_METHODS) addOwner(operationOwners, item[method]?.operationId, owner); + } + } + } else { + for (const id of Object.keys(spec.data?.operations ?? {})) addOwner(operationOwners, id, owner); + const ownMessages = new Set(Object.keys(spec.data?.components?.messages ?? {})); + for (const channel of Object.values(spec.data?.channels ?? {})) { + for (const id of Object.keys(channel?.messages ?? {})) ownMessages.add(id); + } + for (const id of ownMessages) addOwner(messageOwners, id, owner); + } + } + + for (const [id, owners] of operationOwners) { + if (owners.size > 1) { + findings.push( + driverFinding( + coverageRel, + 'coverage-ambiguous-reference', + `Operation identifier "${id}" is declared by multiple surfaces: ${[...owners].join(', ')}.`, + ), + ); + } + } + for (const [id, owners] of messageOwners) { + if (owners.size > 1) { + findings.push( + driverFinding( + coverageRel, + 'coverage-ambiguous-reference', + `Message key "${id}" is declared by multiple surfaces: ${[...owners].join(', ')}.`, + ), + ); + } + } + + return { operationOwners, messageOwners }; +} + +async function validateRequirementCoverage(cfg, specs, hasDeclaredSurface, noApi, rel, findings) { + const coverageAbs = path.join(cfg.repoRoot, 'api', 'coverage.yaml'); + const coverageRel = rel(coverageAbs); + const file = await readOptionalText(coverageAbs); + + if (noApi) { + if (file.exists) { + findings.push( + driverFinding( + coverageRel, + 'coverage-without-api', + 'api/coverage.yaml must be removed when api/index.yaml declares noApi: true.', + ), + ); + } + return; + } + if (!hasDeclaredSurface) return; + const markers = await scanRequirementMarkers(cfg.repoRoot, rel, findings); + if (markers.size === 0) { + findings.push( + driverFinding( + 'spec/', + 'requirements-missing', + 'Declared API surfaces require at least one active CFR-formatted requirement under spec/**/*.md.', + ), + ); + } + if (!file.exists || !file.content.trim()) { + findings.push( + driverFinding( + coverageRel, + file.exists ? 'coverage-empty' : 'coverage-missing', + 'Declared API surfaces require a non-empty api/coverage.yaml requirement mapping.', + ), + ); + return; + } + + const doc = parseYamlObject(file.content, coverageRel); + const { operationOwners, messageOwners } = collectReferenceInventory(specs, rel, findings, coverageRel); + if (doc.version !== 1) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', 'api/coverage.yaml must declare version: 1.')); + } + if (!Array.isArray(doc.requirements) || doc.requirements.length === 0) { + findings.push( + driverFinding(coverageRel, 'coverage-invalid', 'requirements must be a non-empty array.'), + ); + return; + } + + const seenIds = new Set(); + for (const [i, entry] of doc.requirements.entries()) { + const label = `requirements[${i}]`; + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label} must be an object.`)); + continue; + } + if (typeof entry.id !== 'string' || !REQUIREMENT_ID_RE.test(entry.id)) { + findings.push( + driverFinding( + coverageRel, + 'coverage-invalid', + `${label}.id must match ${REQUIREMENT_ID_RE}.`, + ), + ); + } else if (seenIds.has(entry.id)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `Requirement id "${entry.id}" is duplicated.`)); + } else { + seenIds.add(entry.id); + if (!markers.has(entry.id)) { + findings.push( + driverFinding( + coverageRel, + 'coverage-unknown-requirement', + `Coverage id "${entry.id}" has no matching marker under spec/**/*.md.`, + ), + ); + } + } + + if (!DISPOSITIONS.has(entry.disposition)) { + findings.push( + driverFinding( + coverageRel, + 'coverage-invalid', + `${label}.disposition must be one of ${[...DISPOSITIONS].join(', ')}.`, + ), + ); + continue; + } + + const allowedKeys = { + operation: new Set(['id', 'disposition', 'operations']), + message: new Set(['id', 'disposition', 'messages']), + external: new Set(['id', 'disposition', 'reference']), + 'non-api': new Set(['id', 'disposition', 'rationale']), + planned: new Set(['id', 'disposition', 'issue']), + }[entry.disposition]; + const extras = Object.keys(entry).filter((key) => !allowedKeys.has(key)); + if (extras.length) { + findings.push( + driverFinding( + coverageRel, + 'coverage-invalid', + `${label} has fields incompatible with ${entry.disposition}: ${extras.join(', ')}.`, + ), + ); + } + + if (entry.disposition === 'operation') { + if (!nonEmptyStrings(entry.operations)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.operations must be a non-empty string array.`)); + } else { + for (const id of new Set(entry.operations)) { + if (!operationOwners.has(id)) { + findings.push( + driverFinding(coverageRel, 'coverage-missing-reference', `${label} references unknown operation "${id}".`), + ); + } + } + } + } else if (entry.disposition === 'message') { + if (!nonEmptyStrings(entry.messages)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.messages must be a non-empty string array.`)); + } else { + for (const id of new Set(entry.messages)) { + if (!messageOwners.has(id)) { + findings.push( + driverFinding(coverageRel, 'coverage-missing-reference', `${label} references unknown message "${id}".`), + ); + } + } + } + } else if (entry.disposition === 'external') { + if (!isHttpUrl(entry.reference)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.reference must be an http(s) URL.`)); + } + } else if (entry.disposition === 'non-api') { + if (typeof entry.rationale !== 'string' || entry.rationale.trim().length < 3) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.rationale is required.`)); + } + } else { + if (!isHttpUrl(entry.issue)) { + findings.push(driverFinding(coverageRel, 'coverage-invalid', `${label}.issue must be an http(s) URL.`)); + } else { + findings.push( + driverFinding( + coverageRel, + 'coverage-planned', + `${label} remains planned and is not implemented: ${entry.issue}.`, + { severity: cfg.mode === 'conformance' ? 'error' : 'warn' }, + ), + ); + } + } + } + + for (const [id, marker] of markers) { + if (!seenIds.has(id)) { + findings.push( + driverFinding( + coverageRel, + 'coverage-missing-requirement', + `Requirement "${id}" at ${marker.location} is not represented in api/coverage.yaml.`, + ), + ); + } + } +} + +// -------------------------------------------------------------------------------------- +// Base validators (§20.1) +// -------------------------------------------------------------------------------------- + +function runCommand(cmd, args) { + return new Promise((resolve) => { + let child; + try { + child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err) { + resolve({ code: null, stdout: '', stderr: '', spawnError: err }); + return; + } + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (d) => (stdout += d.toString())); + child.stderr.on('data', (d) => (stderr += d.toString())); + child.on('error', (err) => resolve({ code: null, stdout, stderr, spawnError: err })); + child.on('close', (code) => resolve({ code, stdout, stderr, spawnError: null })); + }); +} + +function trimOutput(s) { + const t = s.trim(); + const max = 800; + return t.length > max ? `${t.slice(0, max)} …[truncated]` : t; +} + +// Result shape: { ok } | { notice } | { finding: }. +async function validateOpenapi(absPath) { + const r = await runCommand('openapi-spec-validator', [absPath]); + if (r.spawnError) { + return { + unavailable: + `openapi-spec-validator not found; skipping OpenAPI base validation (§20.1). ` + + `Install with: pip install openapi-spec-validator`, + }; + } + if (r.code === 0) return { ok: true }; + return { finding: `openapi-spec-validator: ${trimOutput(`${r.stdout}\n${r.stderr}`)}` }; +} + +// npx may report a missing package rather than a validation failure; those messages mean +// "fall back to a globally installed asyncapi CLI", not "the spec is invalid". +const NPX_UNAVAILABLE_RE = + /could not determine executable|npm error|not been installed|could not resolve|E404|command not found|no such file/i; + +async function validateAsyncapi(absPath) { + const npx = await runCommand('npx', ['--no-install', '@asyncapi/cli', 'validate', absPath]); + if (!npx.spawnError) { + if (npx.code === 0) return { ok: true }; + const out = `${npx.stdout}\n${npx.stderr}`; + if (!NPX_UNAVAILABLE_RE.test(out)) { + return { finding: `@asyncapi/cli: ${trimOutput(out)}` }; + } + // else: package unavailable via npx -> fall through to a global asyncapi binary. + } + + const global = await runCommand('asyncapi', ['validate', absPath]); + if (global.spawnError) { + return { + unavailable: + `AsyncAPI CLI not found; skipping AsyncAPI base validation (§20.1). ` + + `Install with: npm i -g @asyncapi/cli`, + }; + } + if (global.code === 0) return { ok: true }; + return { finding: `asyncapi validate: ${trimOutput(`${global.stdout}\n${global.stderr}`)}` }; +} + +async function runBaseValidator(kind, absPath, rel, findings, notices, mode) { + const result = + kind === 'openapi' ? await validateOpenapi(absPath) : await validateAsyncapi(absPath); + if (result.unavailable) { + if (mode === 'conformance') { + findings.push( + driverFinding(rel(absPath), 'base-validator-unavailable', result.unavailable, { + guideRule: '20.1', + }), + ); + } else { + notices.push(result.unavailable); + } + } else if (result.finding) { + findings.push( + driverFinding(rel(absPath), 'base-validator', result.finding, { guideRule: '20.1' }), + ); + } +} + +// -------------------------------------------------------------------------------------- +// Spectral +// -------------------------------------------------------------------------------------- + +async function loadRuleset(rulesetPath) { + try { + await fsp.access(rulesetPath); + } catch { + throw new OperationalError(`Ruleset not found: ${rulesetPath}`); + } + try { + return await bundleAndLoadRuleset(rulesetPath, { fs, fetch: globalThis.fetch }); + } catch (err) { + throw new OperationalError(`Failed to load ruleset ${rulesetPath}: ${err.message}`); + } +} + +// A guide rule id is the Spectral code with the `govstack-` prefix removed and any +// trailing `-suffix` after the numeric id dropped: govstack-2.5-contact -> 2.5. +function guideRuleFromCode(code) { + const s = String(code).replace(/^govstack-/, ''); + const m = s.match(/^(\d+(?:\.\d+)?)/); + return m ? m[1] : null; +} + +function mapSpectralResult(r, relPath) { + const severity = SEVERITY_NAME[r.severity] ?? 'info'; + return { + file: relPath, + code: String(r.code), + guideRule: guideRuleFromCode(r.code), + severity, + message: r.message, + jsonPath: Array.isArray(r.path) ? r.path : [], + range: r.range ?? null, + documentationUrl: r.documentationUrl ?? null, + }; +} + +// -------------------------------------------------------------------------------------- +// Guide-version declaration & exceptions (§20.3) +// -------------------------------------------------------------------------------------- + +let cachedGuideCatalogue; +function getGuideCatalogue() { + if (cachedGuideCatalogue) return cachedGuideCatalogue; + try { + const coverage = YAML.parse(fs.readFileSync(path.join(HERE, 'coverage.yaml'), 'utf8')); + const catalogue = YAML.parse(fs.readFileSync(path.join(HERE, '..', 'rules.yaml'), 'utf8')); + if (coverage?.guide_version !== SUPPORTED_GUIDE_VERSION) { + throw new Error( + `coverage.yaml guide_version is ${coverage?.guide_version ?? '(missing)'}, expected ${SUPPORTED_GUIDE_VERSION}`, + ); + } + if (catalogue?.version !== SUPPORTED_GUIDE_VERSION) { + throw new Error( + `rules.yaml version is ${catalogue?.version ?? '(missing)'}, expected ${SUPPORTED_GUIDE_VERSION}`, + ); + } + cachedGuideCatalogue = { + version: SUPPORTED_GUIDE_VERSION, + ids: new Set((catalogue.rules ?? []).map((rule) => String(rule.id))), + }; + return cachedGuideCatalogue; + } catch (err) { + throw new OperationalError(`Cannot load the supported guide catalogue: ${err.message}`); + } +} + +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +const EXCEPTION_KEYS = new Set([ + 'rule', + 'scope', + 'rationale', + 'record', + 'reviewedBy', + 'reviewedAt', + 'expiresAt', +]); +const JSON_POINTER_RE = /^(?:\/(?:[^~]|~[01])*)*$/; + +function decodeJsonPointer(pointer) { + if (pointer === '') return []; + if (typeof pointer !== 'string' || !JSON_POINTER_RE.test(pointer)) return null; + return pointer + .slice(1) + .split('/') + .map((token) => token.replaceAll('~1', '/').replaceAll('~0', '~')); +} + +function applicableException(exceptions, finding) { + const candidates = exceptions.get(finding.guideRule) ?? []; + const findingPath = Array.isArray(finding.jsonPath) ? finding.jsonPath.map(String) : []; + return candidates.find( + (candidate) => + candidate.decodedScope.length <= findingPath.length && + candidate.decodedScope.every((token, i) => token === findingPath[i]), + ); +} + +function consumeGuideDeclaration(specData, relPath, findings, mode) { + const { version, ids } = getGuideCatalogue(); + const decl = specData?.info?.['x-govstack-api-guide']; + const severity = mode === 'conformance' ? 'error' : 'warn'; + if (!decl || typeof decl !== 'object' || Array.isArray(decl)) { + findings.push( + driverFinding( + relPath, + 'guide-version', + `Specification must declare info.x-govstack-api-guide.version and rulesetVersion as ${version}.`, + { guideRule: '20.3', severity }, + ), + ); + return { exceptions: new Map() }; + } + if (decl.version !== version) { + findings.push( + driverFinding( + relPath, + 'guide-version', + `Specification declares guide version ${String(decl.version)}, but this linter supports exactly ${version}.`, + { guideRule: '20.3', severity }, + ), + ); + } + if (decl.rulesetVersion !== version) { + findings.push( + driverFinding( + relPath, + 'guide-version', + `Specification declares ruleset version ${String(decl.rulesetVersion)}, but this linter supports exactly ${version}.`, + { guideRule: '20.3', severity }, + ), + ); + } + + const exceptions = new Map(); + if (decl.exceptions === undefined) return { exceptions }; + if (!Array.isArray(decl.exceptions)) { + findings.push( + driverFinding(relPath, 'guide-exception', 'x-govstack-api-guide.exceptions must be an array.', { + guideRule: '20.3', + }), + ); + return { exceptions }; + } + + const today = new Date(); + for (const [i, item] of decl.exceptions.entries()) { + const label = `x-govstack-api-guide.exceptions[${i}]`; + const problems = []; + if (!item || typeof item !== 'object' || Array.isArray(item)) { + problems.push('must be an object'); + } else { + const extra = Object.keys(item).filter((key) => !EXCEPTION_KEYS.has(key)); + if (extra.length) problems.push(`has unsupported fields: ${extra.join(', ')}`); + if (typeof item.rule !== 'string' || !ids.has(item.rule)) problems.push('must name a known guide rule'); + const decodedScope = decodeJsonPointer(item.scope); + if (decodedScope === null) problems.push('scope must be a valid RFC 6901 JSON Pointer'); + if (typeof item.rationale !== 'string' || item.rationale.trim().length < 10) { + problems.push('requires a substantive rationale'); + } + if (!isHttpsUrl(item.record)) problems.push('record must be an approved HTTPS link'); + if (typeof item.reviewedBy !== 'string' || item.reviewedBy.trim().length < 2) { + problems.push('reviewedBy is required'); + } + if (!DATE_RE.test(item.reviewedAt ?? '')) problems.push('reviewedAt must be YYYY-MM-DD'); + if (!DATE_RE.test(item.expiresAt ?? '')) problems.push('expiresAt must be YYYY-MM-DD'); + if (DATE_RE.test(item.reviewedAt ?? '') && DATE_RE.test(item.expiresAt ?? '')) { + const reviewed = new Date(`${item.reviewedAt}T00:00:00Z`); + const expires = new Date(`${item.expiresAt}T23:59:59Z`); + if (reviewed > expires) problems.push('expiresAt must be after reviewedAt'); + if (expires < today) problems.push('exception has expired'); + } + const duplicate = (exceptions.get(item.rule) ?? []).some( + (candidate) => candidate.scope === item.scope, + ); + if (duplicate) problems.push(`duplicates exception for rule ${item.rule} at scope ${item.scope}`); + } + if (problems.length) { + findings.push( + driverFinding(relPath, 'guide-exception', `${label} ${problems.join('; ')}.`, { + guideRule: '20.3', + }), + ); + continue; + } + const candidates = exceptions.get(item.rule) ?? []; + candidates.push({ ...item, decodedScope: decodeJsonPointer(item.scope) }); + exceptions.set(item.rule, candidates); + } + return { exceptions }; +} + +// -------------------------------------------------------------------------------------- +// Reporting +// -------------------------------------------------------------------------------------- + +function locationOf(finding) { + if (finding.range && finding.range.start) { + return `${finding.range.start.line + 1}:${finding.range.start.character + 1}`; + } + return null; +} + +function groupFindings(findings) { + const groups = new Map(); + for (const f of findings) { + let g = groups.get(f.code); + if (!g) { + g = { + code: f.code, + guideRule: f.guideRule, + severity: f.severity, + severityNum: SEVERITY_NUM[f.severity] ?? 2, + message: f.message, + documentationUrl: f.documentationUrl, + locations: [], + count: 0, + }; + groups.set(f.code, g); + } + g.count += 1; + const loc = locationOf(f); + if (loc && g.locations.length < 5) g.locations.push(loc); + } + return [...groups.values()].sort( + (a, b) => a.severityNum - b.severityNum || a.code.localeCompare(b.code), + ); +} + +function renderText(report) { + const { files, notices, suppressed, summary, failOn, mode, failed, noSpecBanner } = report; + const lines = []; + + if (noSpecBanner) { + lines.push('════════════════════════════════════════════════════════════════'); + lines.push(' NOTICE: no API spec files found to lint.'); + lines.push(' Expected api/openapi.yaml and/or api/asyncapi.yaml.'); + lines.push('════════════════════════════════════════════════════════════════'); + } + + for (const f of files) { + if (f.findings.length === 0) continue; + lines.push(''); + lines.push(f.path); + for (const g of groupFindings(f.findings)) { + const rule = g.guideRule ? ` (§${g.guideRule})` : ''; + lines.push(` [${g.severity}] ${g.code}${rule} ×${g.count}`); + lines.push(` ${g.message}`); + if (g.locations.length) { + const more = g.count > g.locations.length ? ', …' : ''; + lines.push(` at ${g.locations.join(', ')}${more}`); + } + if (g.documentationUrl) lines.push(` docs: ${g.documentationUrl}`); + } + } + + if (suppressed.length) { + lines.push(''); + lines.push(`Suppressed (${suppressed.length}) — excepted via info.x-govstack-api-guide:`); + for (const s of suppressed) { + const rule = s.guideRule ? ` (§${s.guideRule})` : ''; + const record = s.exceptionRecord ? `record: ${s.exceptionRecord}` : 'record: (none)'; + lines.push(` [${s.severity}] ${s.code}${rule} — ${s.file} — ${record}`); + } + } + + if (notices.length) { + lines.push(''); + lines.push(`Notices (${notices.length}):`); + for (const n of notices) lines.push(` - ${n}`); + } + + lines.push(''); + lines.push( + `Summary: ${summary.filesLinted} file(s) linted; ${summary.errors} error(s), ` + + `${summary.warnings} warning(s), ${summary.info} info; ${summary.suppressed} suppressed.`, + ); + lines.push(`Result: ${failed ? 'FAIL' : 'PASS'} (mode=${mode}, fail-on=${failOn}).`); + return lines.join('\n'); +} + +function renderJson(report) { + const { files, notices, summary, failOn, mode, failed } = report; + return JSON.stringify( + { + files: files.map((f) => ({ + path: f.path, + findings: f.findings.map((x) => ({ + code: x.code, + guideRule: x.guideRule, + severity: x.severity, + message: x.message, + path: x.jsonPath, + range: x.range, + documentationUrl: x.documentationUrl, + })), + suppressed: f.suppressed.map((x) => ({ + code: x.code, + guideRule: x.guideRule, + severity: x.severity, + message: x.message, + path: x.jsonPath, + range: x.range, + documentationUrl: x.documentationUrl, + exceptionRecord: x.exceptionRecord, + })), + })), + notices, + summary, + failOn, + mode, + failed, + }, + null, + 2, + ); +} + +// -------------------------------------------------------------------------------------- +// Main +// -------------------------------------------------------------------------------------- + +async function main(argv) { + const values = parseCliArgs(argv); + const cfg = resolveConfig(values); + const rel = (p) => { + const r = path.relative(cfg.repoRoot, p); + return r === '' || r.startsWith('..') ? p : r; + }; + + const findings = []; + const suppressed = []; + const notices = []; + getGuideCatalogue(); + + // --- Discover and load declared spec files ------------------------------------------- + const discovery = await discoverApiDeclarations(cfg, rel, findings, notices); + const specs = await loadDeclaredSpecs(discovery.declarations, rel, findings); + const hasDeclaredSurface = discovery.declarations.length > 0 || discovery.standardSurfaces.length > 0; + + // --- File-tree checks (§2.2/§2.3/§3.2/§3.3) ----------------------------------------- + await checkLegacySwagger(cfg.repoRoot, rel, findings); + const skipAbs = new Set(discovery.declarations.map((entry) => path.resolve(entry.abs))); + skipAbs.add(path.join(cfg.repoRoot, 'api', 'swagger.yaml')); + skipAbs.add(path.join(cfg.repoRoot, 'api', 'swagger.json')); + await scanDivergentCopies(cfg.repoRoot, skipAbs, rel, findings); + await validateRequirementCoverage(cfg, specs, hasDeclaredSurface, discovery.noApi, rel, findings); + if (discovery.noApi) notices.push('api/index.yaml explicitly declares that this BB exposes no API surface.'); + for (const surface of discovery.standardSurfaces) { + const message = + `Standard-defined API surface "${surface.name}" is inventoried at ${surface.reference}; ` + + 'its protocol-specific conformance is not evaluated by this OpenAPI/AsyncAPI linter.'; + if (cfg.mode === 'conformance') { + findings.push( + driverFinding( + 'api/index.yaml', + 'standard-surface-unverified', + `${message} Conformance remains blocked until GovStack approves a standard-surface registry or profile.`, + ), + ); + } else { + notices.push(message); + } + } + + const noSpec = !hasDeclaredSurface; + + // --- Spectral (§20.2) + base validators (§20.1) + guide declaration (§20.3) ---------- + let spectral; + for (const spec of specs) { + const relSpec = rel(spec.abs); + + // §20.3: version comparison + exception set for this spec. + const { exceptions } = consumeGuideDeclaration(spec.data, relSpec, findings, cfg.mode); + + // §20.1 base validator. + if (!cfg.skipValidators) { + await runBaseValidator(spec.kind, spec.abs, rel, findings, notices, cfg.mode); + } + + // §20.2 Spectral. + if (!spectral) { + const ruleset = await loadRuleset(cfg.rulesetPath); + spectral = new Spectral(); + spectral.setRuleset(ruleset); + } + const doc = new Document(spec.content, Parsers.Yaml, spec.abs); + let results; + try { + results = await spectral.run(doc); + } catch (err) { + throw new OperationalError(`Spectral failed on ${relSpec}: ${err.message}`); + } + + for (const r of results) { + const finding = mapSpectralResult(r, relSpec); + const exception = finding.guideRule ? applicableException(exceptions, finding) : null; + if (exception) { + suppressed.push({ ...finding, exceptionRecord: exception.record }); + } else { + findings.push(finding); + } + } + + } + + // --- Assemble per-file report -------------------------------------------------------- + const fileMap = new Map(); + const ensureFile = (p) => { + let e = fileMap.get(p); + if (!e) { + e = { path: p, findings: [], suppressed: [] }; + fileMap.set(p, e); + } + return e; + }; + // Present specs appear first, in a deterministic order, even when clean. + for (const spec of specs) ensureFile(rel(spec.abs)); + for (const f of findings) ensureFile(f.file).findings.push(f); + for (const s of suppressed) ensureFile(s.file).suppressed.push(s); + const files = [...fileMap.values()]; + + // --- Summary + threshold verdict ----------------------------------------------------- + const threshold = FAIL_ON_THRESHOLD[cfg.failOn]; + let errors = 0; + let warnings = 0; + let info = 0; + let failed = false; + for (const f of findings) { + const n = SEVERITY_NUM[f.severity] ?? 2; + if (n === 0) errors += 1; + else if (n === 1) warnings += 1; + else if (n === 2) info += 1; + if (n <= threshold) failed = true; + } + + const report = { + files, + notices, + suppressed, + summary: { + filesLinted: specs.length, + errors, + warnings, + info, + suppressed: suppressed.length, + }, + failOn: cfg.failOn, + mode: cfg.mode, + failed, + noSpecBanner: noSpec && !discovery.noApi, + }; + + const output = cfg.format === 'json' ? renderJson(report) : renderText(report); + process.stdout.write(`${output}\n`); + + return failed ? 1 : 0; +} + +main(process.argv.slice(2)) + .then((code) => process.exit(code)) + .catch((err) => { + if (err instanceof OperationalError) { + process.stderr.write(`error: ${err.message}\n`); + } else { + process.stderr.write(`error: unexpected failure: ${err?.stack ?? err}\n`); + } + process.exit(2); + }); diff --git a/api-design-guide/linter/coverage.yaml b/api-design-guide/linter/coverage.yaml new file mode 100644 index 0000000..8f82907 --- /dev/null +++ b/api-design-guide/linter/coverage.yaml @@ -0,0 +1,871 @@ +# GovStack API lint coverage manifest: every guide rule and how (or why not) the linter covers it. +# Hand-maintained alongside the rulesets; machine-checked by tests/coverage.test.mjs against +# ../rules.yaml (all 171 ids present exactly once) and against the rule names actually defined +# in ruleset.yaml / strict.yaml (spectral_rules lists must match, both directions). +# +# status values: +# implemented - fully checked by the Spectral rules listed in spectral_rules +# driver - checked by cli.mjs (file-tree layout, base validators), not by Spectral +# partial-proxy - Spectral checks an automated proxy; note says what it does NOT verify +# strict-only - noisy heuristic, ships only in strict.yaml (opt-in) +# needs-context - not checkable from the document alone; note names the missing input +# runtime - constrains wire behaviour; conformance test-harness territory, not a linter's +# human - review/governance judgment; no useful automated check +# informative - non-normative guide entry; nothing to enforce +guide_version: "0.1.0-draft" +rules: + - id: "2.1" + class: "M" + status: implemented + spectral_rules: [govstack-2.1] + note: "assert openapi is one of the ruleset-qualified patches 3.1.0, 3.1.1, 3.1.2" + - id: "2.2" + class: "M+R" + status: driver + spectral_rules: [] + note: "canonical file at api/openapi.yaml, single entrypoint, all $refs resolve - repo file tree" + - id: "2.3" + class: "R" + status: driver + spectral_rules: [] + note: "no divergent copies elsewhere; md snippets $ref canonical - repo file tree" + - id: "2.4" + class: "M" + status: driver + spectral_rules: [] + note: "run openapi-spec-validator against 3.1 schema" + - id: "2.5" + class: "M" + status: implemented + spectral_rules: [govstack-2.5, govstack-2.5-semver] + note: "info has title/version/description; version matches SemVer regex; contact is advisory" + - id: "2.6" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-2.6] + note: "deterministic: non-empty HTTPS servers, no loopback, no duplicated /vN; meaningful/fake-domain judgment remains review" + - id: "2.7" + class: "M+R" + status: implemented + spectral_rules: [govstack-2.7] + note: "each operation has stable non-empty operationId and description; casing, summary, and tags are advisory" + - id: "2.8" + class: "M+R" + status: needs-context + spectral_rules: [] + note: "conditional Problem/ValidationProblem/FieldError/PageInfo references require the vendored schema-only common file and semantic recognition of where each schema applies" + - id: "3.1" + class: "M" + status: implemented + spectral_rules: [govstack-3.1] + note: "assert asyncapi is a qualified AsyncAPI 3 version (\"3.0.0\" or \"3.1.0\")" + - id: "3.2" + class: "M+R" + status: driver + spectral_rules: [] + note: "canonical api/asyncapi.yaml, one entrypoint - repo file tree" + - id: "3.3" + class: "R" + status: driver + spectral_rules: [] + note: "no divergent AsyncAPI copies - repo file tree" + - id: "3.4" + class: "M" + status: driver + spectral_rules: [] + note: "run AsyncAPI 3.0 parser/validator" + - id: "3.5" + class: "M" + status: implemented + spectral_rules: [govstack-3.5, govstack-3.5-semver] + note: "info has title/version(SemVer)/description; contact is advisory" + - id: "3.6" + class: "M+R" + status: implemented + spectral_rules: [govstack-3.6, govstack-3.6-host] + note: "servers/channels/operations/components.messages non-empty; server host not localhost" + - id: "3.7" + class: "M+R" + status: implemented + spectral_rules: [govstack-3.7] + note: "each op has action(send/receive), description, channel ref, and >=1 msg ref; verify op msgs resolve to channel's messages; summary and tags are advisory" + - id: "3.8" + class: "M" + status: needs-context + spectral_rules: [] + note: "shared components $ref pinned local govstack-asyncapi-common.yaml - needs vendored file" + - id: "3.9" + class: "M" + status: partial-proxy + spectral_rules: [govstack-3.9] + note: "proxy: payload schemas parse as JSON Schema + apply §9/§10 checks to payload props" + - id: "4.1" + class: "M" + status: partial-proxy + spectral_rules: [govstack-4.1] + note: "advisory proxy: warns on every missing schema description; whether a non-obvious schema has a useful description is not mechanically decidable" + - id: "4.2" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-4.2] + note: "advisory body-example presence; enum \"values documented\" proxied by description presence" + - id: "4.3" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-4.3] + note: "regex scan all text for TBD/Lorem ipsum/\"a, b, c\"; cross-BB-name leakage needs external list" + - id: "4.4" + class: "R" + status: strict-only + spectral_rules: [govstack-4.4] + note: "proxy: flag identical descriptions reused across operations" + - id: "4.5" + class: "M+R" + status: driver + spectral_rules: [] + note: "driver validates canonical discovery, OpenAPI/AsyncAPI paths, explicit noApi reasons, and undeclared spec copies; protocol-standard entries block conformance until an approved registry/profile can validate them" + - id: "4.6" + class: "M+R" + status: driver + spectral_rules: [] + note: "driver validates CFR requirement headings, bodies, canonical IDs and classifiers; active coverage ID sets; disposition evidence; operation/message references; uniqueness; and planned gaps" + - id: "5.1" + class: "M" + status: partial-proxy + spectral_rules: [govstack-5.1] + note: "advisory proxy: checks the recommended /v{N}/ path default and standard endpoint exemptions; does not prove that a different declared mechanism exposes the major version unambiguously" + - id: "5.2" + class: "M+R" + status: strict-only + spectral_rules: [govstack-5.2] + note: "proxy: resource segment plural heuristic" + - id: "5.3" + class: "M" + status: implemented + spectral_rules: [govstack-5.3] + note: "advisory default: each non-param path segment kebab-case" + - id: "5.4" + class: "M" + status: implemented + spectral_rules: [govstack-5.4] + note: "<=2 non-param levels after /v{N}/; {param} segments do not count (per mandated 15.5/16.11 action paths)" + - id: "5.5" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-5.5] + note: "proxy: flag single-resource GET/DELETE identifying via query *id param" + - id: "5.6" + class: "M" + status: implemented + spectral_rules: [govstack-5.6] + note: "advisory default: query-param names camelCase" + - id: "5.7" + class: "M+R" + status: strict-only + spectral_rules: [govstack-5.7] + note: "proxy: verb-like final path segments vs verb list" + - id: "5.8" + class: "R" + status: strict-only + spectral_rules: [govstack-5.8] + note: "proxy: action-style paths follow /{col}/{id}/{verb}" + - id: "5.9" + class: "M+R" + status: implemented + spectral_rules: [govstack-5.9-presence, govstack-5.9-media-type, govstack-5.9-status-codes, govstack-5.9-no-auth] + note: "/health present, unversioned, 200 media application/json, both 200 and 503 declared, no auth; the media-type check is scoped to the 200 response so the 11.1 problem+json error responses stay satisfiable. Not checked: 'cheap and bounded', 'MUST NOT expose system-internal detail', and the dependency-probing limit, none of which are decidable from the document." + - id: "5.10" + class: "M" + status: implemented + spectral_rules: [govstack-5.10] + note: "the exemptions this rule grants are implemented where they bite (5.1/5.3 in pathSegments, 12.x in s12-collectionPagination, 13.1 by accepting an explicit `security: []`), all reading the closed set from functions/lib/standardEndpoints.js; the rule of its own enforces the prohibition, that these paths declare no mutating method. Whether a GET on such a path is a business resource in disguise is left to review." + - id: "6.1" + class: "M+R" + status: implemented + spectral_rules: [govstack-6.1] + note: "GET operations declare no requestBody" + - id: "6.2" + class: "R" + status: human + spectral_rules: [] + note: "descriptive, no normative keyword" + - id: "6.3" + class: "R" + status: runtime + spectral_rules: [] + note: "replace-entire-resource/idempotent is runtime" + - id: "6.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-6.4] + note: "advisory proxy: recognises the Merge Patch default; registered alternative patch media types and documented semantics require review" + - id: "6.5" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-6.5] + note: "proxy: DELETE responses in {204(no body),200,202}" + - id: "6.6" + class: "M+R" + status: implemented + spectral_rules: [govstack-6.6] + note: "POST .../search returns 200 not 201" + - id: "6.7" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-6.7] + note: "proxy: collection-targeted PUT/PATCH/DELETE require >=1 param" + - id: "7.1" + class: "R" + status: human + spectral_rules: [] + note: "descriptive status mapping, no MUST" + - id: "7.2" + class: "M" + status: implemented + spectral_rules: [govstack-7.2] + note: "every 201 response declares a Location header" + - id: "7.3" + class: "M+R" + status: implemented + spectral_rules: [govstack-7.3] + note: "every 202 response declares Location" + - id: "7.4" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.5" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.6" + class: "M+R" + status: implemented + spectral_rules: [govstack-7.6] + note: "every 401 response declares WWW-Authenticate" + - id: "7.7" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.8" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.9" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.10" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.11" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.12" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.13" + class: "M" + status: implemented + spectral_rules: [govstack-7.13] + note: "every operation declares a 500 response" + - id: "7.14" + class: "M" + status: partial-proxy + spectral_rules: [govstack-7.14, govstack-7.14-creation-status, govstack-7.14-baseline-errors] + note: "proxy: >1 code and >=1 non-2xx; creation-like POSTs expose 201/202; visible security/scope/body/parameter/path shape deterministically requires baseline 401/403/400/404" + - id: "7.15" + class: "R" + status: human + spectral_rules: [] + note: "descriptive" + - id: "7.16" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-7.16] + note: "proxy: resource GETs declare ETag header + 304 (advisory SHOULD); exempts the 5.9 operational endpoints /health and /ready (a liveness probe is not a cacheable resource)" + - id: "7.17" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-7.17] + note: "proxy: PUT/PATCH declare If-Match param + 412 response (advisory)" + - id: "7.18" + class: "M" + status: implemented + spectral_rules: [govstack-7.18] + note: "every 405 response declares Allow header" + - id: "7.19" + class: "M+R" + status: implemented + spectral_rules: [govstack-7.19] + note: "PATCH operations declare a 415 response" + - id: "7.20" + class: "M" + status: implemented + spectral_rules: [govstack-7.20] + note: "problem+json / operation-status responses declare Cache-Control (no-store)" + - id: "7.21" + class: "M" + status: implemented + spectral_rules: [govstack-7.21] + note: "declared 2xx response media types carry non-empty schemas; 204 has no content" + - id: "8.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-8.1] + note: "proxy: no apiKey scheme in query/cookie, no token-like query params" + - id: "8.2" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-8.2] + note: "proxy: ops taking Accept-Language declare Content-Language response header" + - id: "8.3" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-8.3] + note: "proxy: create-POSTs (201) declare Idempotency-Key header param" + - id: "8.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-8.4] + note: "proxy: operations with effective nonempty security declare W3C traceparent request header; runtime propagation remains review/test" + - id: "8.5" + class: "M" + status: implemented + spectral_rules: [govstack-8.5] + note: "no header parameter or response header uses the X- prefix" + - id: "8.6" + class: "R" + status: strict-only + spectral_rules: [govstack-8.6] + note: "proxy: flag param names matching personal-data terms" + - id: "8.7" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-8.7, govstack-8.7-no-legacy] + note: "proxy: 429 responses declare Retry-After and structured RateLimit; deterministic error forbids legacy RateLimit-Limit/Remaining/Reset" + - id: "9.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-9.1] + note: "proxy: responses use application/json; binary/export exception unknowable" + - id: "9.2" + class: "M" + status: implemented + spectral_rules: [govstack-9.2] + note: "advisory: reusable and inline OpenAPI body fields plus AsyncAPI payload fields use camelCase" + - id: "9.3" + class: "M" + status: partial-proxy + spectral_rules: [govstack-9.3] + note: "proxy: flag boolean-looking props typed string / enum [\"true\",\"false\"]" + - id: "9.4" + class: "M" + status: partial-proxy + spectral_rules: [govstack-9.4] + note: "proxy: flag 3.0-style nullable:true (should be type:[...,\"null\"])" + - id: "9.5" + class: "M" + status: implemented + spectral_rules: [govstack-9.5] + note: "advisory: reusable and inline OpenAPI body fields plus AsyncAPI payload fields use ASCII without spaces" + - id: "9.6" + class: "R" + status: strict-only + spectral_rules: [govstack-9.6] + note: "proxy: property names vs abbreviation dictionary" + - id: "9.7" + class: "M" + status: partial-proxy + spectral_rules: [govstack-9.7] + note: "proxy: enum string values SCREAMING_SNAKE_CASE minus the rule's carve-outs (BCP 47 tags, reverse-DNS error codes and event types, media types, x-govstack-* vocabularies, and the JOSE/COSE names not already all-caps). IANA registry membership is not visible in a document, so a registered value outside the listed set is a miss. §12.7 sort keys are a known false positive." + - id: "9.8" + class: "M" + status: implemented + spectral_rules: [govstack-9.8] + note: "top-level resource body schemas don't set additionalProperties:false" + - id: "9.9" + class: "R" + status: partial-proxy + spectral_rules: [govstack-9.9] + note: "proxy: flag closed enums lacking x-extensible-enum/UNKNOWN fallback" + - id: "9.10" + class: "M+R" + status: strict-only + spectral_rules: [govstack-9.10] + note: "proxy: GovStack-semantic extensions use x-govstack- prefix (weak)" + - id: "9.11" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-9.11] + note: "in-doc: extract bb-code from canonical problem-type URIs, OAuth scopes, event types and logical channel IDs; verify identical + regex ^[a-z][a-z0-9-]{1,30}$; protocol-native address values are excluded; ecosystem uniqueness needs registry" + - id: "10.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.1] + note: "proxy: resource id fields string/uuid not integer" + - id: "10.2" + class: "M" + status: partial-proxy + spectral_rules: [govstack-10.2] + note: "proxy: timestamp-named fields declare format:date-time" + - id: "10.3" + class: "M" + status: partial-proxy + spectral_rules: [govstack-10.3] + note: "proxy: date-named fields declare format:date" + - id: "10.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.4] + note: "proxy: money fields are {amount:string,currency} object, not type:number" + - id: "10.5" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.5] + note: "proxy: phone fields string with E.164 pattern" + - id: "10.6" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.6] + note: "proxy: email fields declare format:email" + - id: "10.7" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.7] + note: "proxy: base64 fields declare contentEncoding:base64 + maxLength" + - id: "10.8" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.8] + note: "proxy: country fields match ^[A-Z]{2}$ / ISO enum" + - id: "10.9" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.9] + note: "proxy: language fields match BCP-47 pattern" + - id: "10.10" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-10.10] + note: "proxy: currency fields match ^[A-Z]{3}$" + - id: "10.11" + class: "M" + status: implemented + spectral_rules: [govstack-10.11-openapi, govstack-10.11-asyncapi] + note: "no declared media type or contentType sets charset to an encoding other than UTF-8. Payload bytes themselves are outside a document linter's reach." + - id: "11.1" + class: "M" + status: implemented + spectral_rules: [govstack-11.1] + note: "every 4xx/5xx response uses application/problem+json. OpenAPI surface only (problem+json/status-code mechanism has no AsyncAPI equivalent)." + - id: "11.2" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-11.2, govstack-11.2-type] + note: "problem schema has type/title/status, omits code/timestamp after ref resolution, and literal examples use the canonical type URI; status/body equality, URI dereferenceability and no-sensitive-detail clauses require review or instance tests" + - id: "11.3" + class: "M" + status: implemented + spectral_rules: [govstack-11.3] + note: "problem schema requires traceId after reference resolution; runtime equality with the effective W3C traceparent is not checked" + - id: "11.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-11.4] + note: "when an HTTP problem schema declares errors, it requires errors[] items with pointer/message and omits field-level code; the linter does not infer field attribution from status codes or semantics" + - id: "11.5" + class: "R" + status: runtime + spectral_rules: [] + note: "problem fields stable across localised responses - runtime" + - id: "11.6" + class: "M+R" + status: needs-context + spectral_rules: [] + note: "shared GovStackAsyncError reference and transport-specific rejection binding require common-artifact resolution; §17.16 has a weaker in-document proxy" + - id: "12.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-12.1] + note: "proxy: list endpoints (array response) declare pagination params/envelope; exempts the 5.10 standard unversioned endpoints (not collections) and collections whose response declares its own maxItems bound" + - id: "12.2" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-12.2] + note: "proxy: paginated lists declare pageSize+cursor; exempts the 5.9 operational endpoints /health and /ready" + - id: "12.3" + class: "M" + status: partial-proxy + spectral_rules: [govstack-12.3] + note: "proxy: paginated response schema requires items and pageInfo with a nullable, non-empty nextCursor and does not declare hasMore; runtime cursor availability and total exact/estimated plus snapshot/current semantics require review" + - id: "12.4" + class: "M+R" + status: implemented + spectral_rules: [govstack-12.4] + note: "pageSize param schema has default and maximum; exempts the 5.10 standard unversioned endpoints and collections bounded by their own maxItems, on the same terms as 12.1" + - id: "12.5" + class: "R" + status: human + spectral_rules: [] + note: "permissive MAY" + - id: "12.6" + class: "M+R" + status: implemented + spectral_rules: [govstack-12.6] + note: "if offset param present, envelope {items,offset,limit,total} with total required" + - id: "12.7" + class: "M" + status: partial-proxy + spectral_rules: [govstack-12.7-name, govstack-12.7-grammar] + note: "advisory proxy: checks the recommended sort name and field/-field grammar; allowed fields, direction documentation, default order, and stable tie-breaker require review" + - id: "12.8" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-12.8] + note: "proxy: flag operator-style filter params (field[gte], generic filter)" + - id: "12.9" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-12.9-body, govstack-12.9-response] + note: "proxy: .../search carries pageSize/cursor in body + §12.3 envelope" + - id: "12.10" + class: "informative" + status: informative + spectral_rules: [] + note: "out-of-scope statement (informative)" + - id: "13.1" + class: "M" + status: implemented + spectral_rules: [govstack-13.1] + note: "root security + securitySchemes present and every operation covered (or explicit override); the 5.10 standard unversioned endpoints satisfy it with an explicit `security: []`, which counts as that override" + - id: "13.2" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-13.2, govstack-13.2-forbidden-flows] + note: "proxy: an openIdConnect/oauth2 scheme exists, or the resource-server alternative (type http, scheme bearer, bearerFormat JWT); deterministic error forbids password and implicit OAuth2 flows. Not checked: that the resource-server case really is one, or that its accepted issuers and required audience are documented" + - id: "13.3" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-13.3] + note: "proxy: an mTLS/client-credentials/X509 scheme exists" + - id: "13.4" + class: "M" + status: partial-proxy + spectral_rules: [govstack-13.4] + note: "proxy: scope strings match bb:{bb-code}:{resource}:{action}. Only fires where scopes are declared, so an API authorizing on token claims instead is silent here; that its required claims are documented per operation is left to review" + - id: "13.5" + class: "M+R" + status: implemented + spectral_rules: [govstack-13.5] + note: "no apiKey scheme in query/cookie; token schemes use Authorization header" + - id: "13.6" + class: "R" + status: partial-proxy + spectral_rules: [govstack-13.6] + note: "proxy: apiKey applied only to operational (/health-like) ops" + - id: "13.7" + class: "M+R" + status: human + spectral_rules: [] + note: "externally reachable surface classification, equivalent AsyncAPI transport protection, negotiated TLS version, and deployment TLS configuration require protocol and runtime review; §2.6 separately checks declared OpenAPI server URLs" + - id: "14.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-14.1] + note: "proxy: create-POSTs (201) accept Idempotency-Key" + - id: "14.2" + class: "R" + status: runtime + spectral_rules: [] + note: "key opacity/client-generation runtime" + - id: "14.3" + class: "R" + status: human + spectral_rules: [] + note: "\"document replay window\" prose" + - id: "14.4" + class: "R" + status: runtime + spectral_rules: [] + note: "replay returns original response - runtime" + - id: "14.5" + class: "R" + status: runtime + spectral_rules: [] + note: "422/409 on key-reuse/fingerprint - runtime" + - id: "14.6" + class: "R" + status: human + spectral_rules: [] + note: "\"document per-operation duplicate handling\" - human" + - id: "15.1" + class: "M+R" + status: implemented + spectral_rules: [govstack-15.1] + note: "every 202 declares Location" + - id: "15.2" + class: "M+R" + status: human + spectral_rules: [] + note: "local Operation identifier and state-dependent shape require semantic review; no ecosystem field names or formats are prescribed" + - id: "15.3" + class: "M+R" + status: human + spectral_rules: [] + note: "terminal/non-terminal lifecycle and result/error/cancellation semantics require review; no fixed enum is prescribed" + - id: "15.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-15.4] + note: "proxy: canonical GET /v1/operations/{operationId} present when Operations used. strengths empty in rules.yaml (class M+R); treated as MUST-flavored." + - id: "15.5" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-15.5] + note: "advisory proxy: checks the conventional cancellation path; does not prove that cancellation support or non-support is documented and discoverable by another mechanism" + - id: "15.6" + class: "R" + status: human + spectral_rules: [] + note: "SHOULD + \"long-running\" semantic - advisory" + - id: "15.7" + class: "R" + status: human + spectral_rules: [] + note: "\"document result retention\" prose" + - id: "16.1" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-16.1] + note: "proxy: OpenAPI push events live under top-level webhooks. Brokered-transport (AsyncAPI) clause not checked." + - id: "16.2" + class: "M" + status: implemented + spectral_rules: [govstack-16.2, govstack-16.2-recommended, govstack-16.2-structured] + note: "event payload schema requires specversion(const \"1.0\")/id/source/type + data, and the webhooks surface must declare application/cloudevents+json rather than a binary-mode application/json body" + - id: "16.3" + class: "M" + status: partial-proxy + spectral_rules: [govstack-16.3, govstack-16.3-no-version] + note: "split proxy: warns on the reverse-DNS default and errors on a pinned major-version segment; BB-code registry membership, long-term stability, and global collision resistance require context" + - id: "16.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-16.4] + note: "proxy: flag source const/example containing host/env/pod tokens" + - id: "16.5" + class: "R" + status: human + spectral_rules: [] + note: "conditional opt-in signing choice and profile documentation require threat-model context" + - id: "16.6" + class: "R" + status: needs-context + spectral_rules: [] + note: "signature metadata naming applies only when a surface opts into signing" + - id: "16.7" + class: "R" + status: runtime + spectral_rules: [] + note: "conditional signed-material contents are verified at runtime when signing is adopted" + - id: "16.8" + class: "R" + status: needs-context + spectral_rules: [] + note: "experimental OpenAPI profile and adopter-supplied key/replay contract need common-file and implementation context" + - id: "16.9" + class: "informative" + status: informative + spectral_rules: [] + note: "readiness criteria for a future shared profile (informative)" + - id: "16.10" + class: "R" + status: human + spectral_rules: [] + note: "\"document delivery-failure contract\" prose" + - id: "16.11" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-16.11] + note: "proxy: subscription create/list/delete endpoints present; conditional signing-key rotation is not checked" + - id: "17.1" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.1] + note: "every operation has action in {send,receive}" + - id: "17.2" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.2] + note: "advisory proxy: logical channel keys match the GovStack reverse-DNS default; stability and native-address mapping require review" + - id: "17.3" + class: "R" + status: strict-only + spectral_rules: [govstack-17.3] + note: "proxy: scan channel addresses/param names for personal-data terms" + - id: "17.4" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.4] + note: "every {param} in channel address declared under parameters with a non-empty description. The rule's enum/examples clauses are [R]: a parameter's value set being closed is not detectable from the document." + - id: "17.5" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.5] + note: "proxy: flag dev/test/prod/broker tokens in channel addresses" + - id: "17.6" + class: "M" + status: partial-proxy + spectral_rules: [govstack-17.6] + note: "recognized CloudEvents Message Objects use application/cloudevents+json and carry the required envelope attributes; identifying every domain event and misplaced domain data requires semantic review" + - id: "17.7" + class: "M" + status: partial-proxy + spectral_rules: [govstack-17.7] + note: "recognized CloudEvents and async-error messages reference the vendored common schemas; identifying every domain event or rejection requires semantic review" + - id: "17.8" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.8] + note: "message headers camelCase/no X- (strong); command idempotency-key needs command identification" + - id: "17.9" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.9] + note: "proxy: localisation headers named acceptLanguage/contentLanguage" + - id: "17.10" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.10] + note: "every operation covered by a security scheme via servers/operations" + - id: "17.11" + class: "R" + status: human + spectral_rules: [] + note: "conditional duplicate-delivery and application de-duplication contract requires protocol context" + - id: "17.12" + class: "R" + status: human + spectral_rules: [] + note: "ordering is documented only when promised; scope/key require protocol context" + - id: "17.13" + class: "R" + status: human + spectral_rules: [] + note: "consumer-visible redelivery/dead-letter/retention/replay capabilities require protocol and deployment context" + - id: "17.14" + class: "R" + status: human + spectral_rules: [] + note: "deciding which values are stable public promises rather than implementation settings requires review" + - id: "17.15" + class: "R" + status: human + spectral_rules: [] + note: "review that standard bindings are used first and custom extensions are not treated as portable contracts" + - id: "17.16" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.16] + note: "proxy: rejection message using §11 error envelope exists + correlation" + - id: "17.17" + class: "M+R" + status: implemented + spectral_rules: [govstack-17.17] + note: "when reply present, declares reply channel + correlationId; else not request-reply" + - id: "17.18" + class: "M+R" + status: human + spectral_rules: [] + note: "\"long-running\" identification + correlation documentation - human" + - id: "17.19" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.19] + note: "proxy: per server protocol, channels/ops declare matching kafka/mqtt/amqp/ws bindings" + - id: "17.20" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-17.20] + note: "advisory proxy: warns when any components.messages entry lacks an example; whether the interaction is already obvious from the schema requires review" + - id: "18.1" + class: "M" + status: partial-proxy + spectral_rules: [govstack-18.1] + note: "proxy: info.version matches SemVer; whether it identifies the contract rather than the implementation and agrees with a nonstandard version mechanism requires context" + - id: "18.2" + class: "M" + status: partial-proxy + spectral_rules: [govstack-18.2-openapi, govstack-18.2-asyncapi] + note: "advisory proxy: checks recommended OpenAPI path and AsyncAPI channel forms; does not prove that another declared mechanism exposes the major version unambiguously" + - id: "18.3" + class: "M+R" + status: needs-context + spectral_rules: [] + note: "backward-compat of minor bump - needs PREVIOUS spec version (oasdiff, out of scope)" + - id: "18.4" + class: "M+R" + status: needs-context + spectral_rules: [] + note: "breaking change => major bump - needs PREVIOUS spec version (oasdiff, out of scope)" + - id: "18.5" + class: "M+R" + status: implemented + spectral_rules: [govstack-18.5] + note: "deprecated operations declare Deprecation + Sunset response headers" + - id: "18.6" + class: "informative" + status: informative + spectral_rules: [] + note: "informative" + - id: "18.7" + class: "M+R" + status: implemented + spectral_rules: [govstack-18.7, govstack-18.7-description] + note: "validate x-govstack-deprecated shape (since/sunset/replacement/reason) + description" + - id: "19.1" + class: "R" + status: runtime + spectral_rules: [] + note: "honouring request language runtime" + - id: "19.2" + class: "R" + status: runtime + spectral_rules: [] + note: "not translating stable content runtime" + - id: "19.3" + class: "R" + status: runtime + spectral_rules: [] + note: "default response language English runtime" + - id: "19.4" + class: "M+R" + status: partial-proxy + spectral_rules: [govstack-19.4-openapi, govstack-19.4-asyncapi] + note: "proxy: localised responses declare Content-Language/contentLanguage header" + - id: "20.1" + class: "M" + status: driver + spectral_rules: [] + note: "run openapi-spec-validator / AsyncAPI parser" + - id: "20.2" + class: "M" + status: human + spectral_rules: [] + note: "meta/self-referential (the ruleset itself)" + - id: "20.3" + class: "M" + status: implemented + spectral_rules: [govstack-20.3, govstack-20.3-exceptions] + note: "exact guide/ruleset versions; scoped exceptions require known rule, rationale, HTTPS record, reviewer, review date, and unexpired expiry" diff --git a/api-design-guide/linter/functions/README.md b/api-design-guide/linter/functions/README.md new file mode 100644 index 0000000..93e86f7 --- /dev/null +++ b/api-design-guide/linter/functions/README.md @@ -0,0 +1,269 @@ +# Shared custom-function library + +Generic, parameterised Spectral functions that the GovStack section rulesets +(`rulesets/sNN.yaml`) call via `functionOptions`. Section agents should code +against **this document** and not read the sources. Read it end to end before +writing rules: most guide checks are a matter of picking the right function and +options, not writing new JavaScript. + +If a shared function almost fits but not quite, do **not** edit it. Either add a +section-local function `functions/sNN-.js` (and list it in your +fragment's `functions:` array) or flag the gap in your report. + +--- + +## How functions load (READ THIS — the load-bearing constraint) + +The ruleset is composed with `extends`: + +``` +strict.yaml ─extends→ ruleset.yaml ─extends→ rulesets/sNN.yaml +``` + +Each **fragment** (`rulesets/sNN.yaml`) that uses a custom function MUST declare, +at the top of the file: + +```yaml +functionsDir: "../functions" # relative to the fragment file +functions: + - valuePattern # EVERY custom function the fragment references + - schemaPropertyNames +``` + +This has been verified to resolve **both** ways: + +- programmatically, via `@stoplight/spectral-ruleset-bundler`'s + `bundleAndLoadRuleset` (what the test harness uses), and +- via the CLI: `npx spectral lint -r ruleset.yaml `. + +…and through the full two-level `extends` chain above. So `functionsDir` + +`functions:` **inside a fragment** is the supported pattern — you do not need to +touch `ruleset.yaml`. + +Rules to obey: + +- List **every** function name your fragment references in its `functions:` + array. A referenced-but-unlisted function is a load error. +- `functionsDir` is always `"../functions"` (fragments live in `rulesets/`). +- The `functions/lib/` directory holds shared internals (`schemaWalk.js`, + `casing.js`, `util.js`). These are **not** Spectral functions and must never + appear in a `functions:` array. Spectral only loads what you list; the bundler + resolves the `lib/` imports automatically. Do not add non-function `.js` files + to the top level of `functions/`. + +## Function contract + +Every function is an ESM default export with the Spectral signature: + +```js +export default function name(targetVal, options, context) { … } +``` + +- **`targetVal`** — the value selected by the rule's `given`. What each function + expects is stated per function below as *Given*. +- **`options`** — your rule's `functionOptions`. +- **`context`** — Spectral context; functions read `context.path` and prefix it + onto every finding's `path`, so findings point at the offending node. +- **Return** — `undefined` when clean, or an array of + `{ message, path }` findings. Functions are defensive: bad/`undefined`/ + non-object input returns `undefined` (never throws). Recursive walkers are + cycle-safe (resolved `$ref`s can be circular). + +Use `message: "[][] {{error}}"` in the rule and let the function +supply the precise text via `{{error}}`. + +--- + +## Function reference + +Options marked *(required)* must be present; everything else is optional. + +### `valuePattern` +Assert a single **string** matches / does not match a regex. +- **Given:** the string itself (`$.info.version`, `$.servers[*].url`, a channel + address). Spectral calls the function once per selected string. +- **Options:** `match` (regex the value MUST match), `notMatch` / + `forbidPattern` (regex it MUST NOT match), `flags` (e.g. `"i"`), `name` + (label used in the message, default `"value"`). Regex strings are compiled + with `new RegExp`; in YAML prefer single quotes so backslashes stay literal. +- **Example:** + ```yaml + given: $.info.version + then: + function: valuePattern + functionOptions: + name: info.version + match: '^\d+\.\d+\.\d+$' + ``` + +### `schemaPropertyNames` +Recursively assert every declared **property name** obeys a casing/pattern. +- **Given:** a JSON Schema (a body schema, `$.components.schemas[*]`). +- **Options:** `casing` (`camel|pascal|kebab|snake|screamingSnake|flat`), + `allowPattern` (regex a name must match), `forbidPattern` (regex a name must + not match, e.g. `'\s'` for spaces / `'[^\x00-\x7F]'` for non-ASCII), `flags`. +- **Example (9.2 camelCase property names):** + ```yaml + given: $.components.schemas[*] + then: { function: schemaPropertyNames, functionOptions: { casing: camel } } + ``` + +### `schemaDescriptions` +Recursively assert schema nodes carry a non-empty `description`. +- **Given:** a JSON Schema. +- **Options:** `mode` — `"properties"` (default: every declared property needs a + description) or `"all"` (every subschema node except pure combinator wrappers + and required-only assertions below `not`); + `includeRoot` (properties mode only: also require a description on the root). +- **Example (4.1):** + ```yaml + then: { function: schemaDescriptions, functionOptions: { mode: all } } + ``` + +### `responseHeaderRequired` +Assert responses whose status matches a pattern declare given header(s), and +optionally that a companion status exists. +- **Given:** an operation's `responses` object + (`$.paths[*][get,put,post,delete,patch].responses`). +- **Options:** `status` *(required)* (status matcher: `"201"`, `"2xx"`, + `"default"`, or a regex), `headers` *(required)* (name or array; matched + case-insensitively unless `caseInsensitive: false`), `requireStatus` (a + response matching `status` must exist), `alsoRequireStatus` (a companion + status matcher that must also be present). +- **Example (7.2 Location on 201):** + ```yaml + then: + function: responseHeaderRequired + functionOptions: { status: "201", headers: [Location] } + ``` + +### `operationResponses` +Assert an operation's response set satisfies presence/absence/count rules. +- **Given:** an operation (`$.paths[*][get,put,post,delete,patch]`). Reads + `.responses`. +- **Options:** `require` (each matcher must match ≥1 status), `requireOneOf` + (≥1 of the matchers), `forbid` (no status may match), `minCount` (≥N response + entries), `minNonSuccess` (≥N non-2xx responses). Matchers are status strings + as above. +- **Example (7.13 must declare 500; 6.6 search must be 200 not 201):** + ```yaml + functionOptions: { require: ["500"] } + functionOptions: { require: ["200"], forbid: ["201"] } + ``` + +### `pathSegments` +Structural checks over the `paths` object. One `check` per rule instance. +- **Given:** `$.paths`. +- **Options:** `check` *(required)* one of `versionPrefix` (keys start with + `/v{N}/`, 5.1), `segmentCasing` (non-version, non-`{param}` segments obey + `casing`, default kebab, 5.3), `maxDepthAfterVersion` (≤ `max` segments after + the version, default 2, 5.4); plus `casing` / `max` for those checks. +- **Example (5.4):** + ```yaml + given: $.paths + then: + function: pathSegments + functionOptions: { check: maxDepthAfterVersion, max: 2 } + ``` + +### `envelopeShape` +Assert a JSON Schema **declares** a required shape (required props, nested +object/array shapes, const/enum/type on leaves). Inspects the schema, does not +validate a data instance. One level of top-level `allOf` is merged. +- **Given:** the schema (a response schema, `$.components.schemas.Foo`). +- **Options (a recursive spec node):** `requiredProperties` (names that must be + in `required`), `forbiddenProperties` (names that must not be declared), + `properties` (`name -> child spec`; each named property must + be declared and is validated by its child), `type`, `const`, `enum` (schema's + `enum` must equal this as a set), `items` (child spec for array `items`). +- **Example (12.3 page envelope):** + ```yaml + functionOptions: + requiredProperties: [items, pageInfo] + properties: + items: { type: array } + pageInfo: { requiredProperties: [nextCursor] } + ``` +- **Example (16.2 CloudEvents payload):** + ```yaml + functionOptions: + requiredProperties: [specversion, id, source, type] + properties: { specversion: { const: "1.0" } } + ``` + +### `securityCoverage` +Check operations are authenticated (or explicitly opted out). +- **Given / modes:** + - default `"covered"` — **Given:** `$`. Every operation must be covered by a + non-empty root `security` or an operation-level `security` (`security: []` + counts as an allowed explicit opt-out). Option `requireSchemes: true` also + requires `components.securitySchemes` to be non-empty. (13.1) + - `mode: "none"` — **Given:** a single operation. It MUST declare + `security: []` (unauthenticated), e.g. the /health GET. (5.9/13.x) +- **Example:** + ```yaml + given: $ + then: { function: securityCoverage, functionOptions: { requireSchemes: true } } + ``` + +### `schemaFieldFormat` +For every property whose **name** matches a pattern, require its schema to +declare type/format/pattern/contentEncoding constraints. Drives the §10 proxies. +- **Given:** a JSON Schema. +- **Options:** `namePattern` *(required)* (regex on the property name), + `nameFlags`, and `require`: `type`, `forbidType` (string or array), `format`, + `formatOneOf`, `contentEncoding`, `pattern` (exact match), `mustDeclare` + (array of keywords that must simply be present, e.g. `["maxLength"]`). +- **Example (10.2 timestamps; 10.4 money not a number):** + ```yaml + functionOptions: { namePattern: 'At$', require: { type: string, format: date-time } } + functionOptions: { namePattern: '(amount|price|balance)$', nameFlags: i, require: { forbidType: number } } + ``` + +### `extensionShape` +Validate presence and shape of an `x-govstack-*` extension on a container. +- **Given:** the container that carries the extension (an operation, `$.info`, + a message). +- **Options:** `extension` *(required)* (the key), `required` (default true), + `valueType` (`string|object|array`), `enum` (allowed string values), + `requiredKeys` (object keys that must be present), `semverKeys` (object keys + whose value must be SemVer), `keyEnums` (`{ key: [values] }`), `keyPatterns` + (`{ key: regexString }`). +- **Example (20.3 guide metadata):** + ```yaml + functionOptions: { extension: x-govstack-api-guide, valueType: object, requiredKeys: [version], semverKeys: [version] } + ``` + +### `mediaTypeExpected` +Assert a `content` map declares / forbids media types. +- **Given:** a `content` object (`…requestBody.content`, a response's + `.content`). Patterns are regexes matched against the media-type keys, so + escape `+`: `application/problem\+json`. +- **Options:** `require` (each pattern must match ≥1 key), `requireOneOf`, + `forbid`. +- **Example (11.1 problem+json on errors; 6.4 PATCH merge-patch):** + ```yaml + functionOptions: { require: ['application/problem\+json'] } + functionOptions: { require: ['application/merge-patch\+json'], forbid: ['^application/json$'] } + ``` + (6.4 uses `require` + `forbid`, not `requireOneOf`: merge-patch is the MUST + baseline and json-patch only a MAY addition, so a json-patch-only body must + still fail.) + +--- + +## Internals (`functions/lib/`, not Spectral functions) + +- **`schemaWalk.js`** — `walkSchema(root, visit, opts)` visits every subschema + (Draft 2020-12 keywords: properties/patternProperties/additional·unevaluated, + items/prefixItems, allOf/anyOf/oneOf/not/if/then/else, $defs, contains, + propertyNames, dependentSchemas). Cycle-safe (WeakSet on node identity — each + distinct schema object is visited once). `forEachProperty(root, cb)` yields + every declared property with its path. +- **`casing.js`** — `matchesCasing(name, type)` and `CASING_TYPES`. +- **`util.js`** — `isObject`, `isNonEmptyString`, `asArray`, `toRegExp` + (returns `undefined` on invalid patterns), `statusMatcher` (expands + `2xx`/`default`/exact/regex), `SEMVER_PATTERN`. + +If you need to walk schemas in a section-local function, import +`./lib/schemaWalk.js` — do not re-implement cycle protection. diff --git a/api-design-guide/linter/functions/envelopeShape.js b/api-design-guide/linter/functions/envelopeShape.js new file mode 100644 index 0000000..9ac2e2a --- /dev/null +++ b/api-design-guide/linter/functions/envelopeShape.js @@ -0,0 +1,110 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * envelopeShape — assert that a JSON Schema *declares* a required shape: + * required properties, nested object/array shapes, and const/enum/type on + * leaves. It inspects the schema (properties/required/const/…), it does NOT + * validate a data instance. Reused for pagination envelopes, problem+json, + * CloudEvents payloads and the Operation resource. + * + * `given` should select the schema, e.g. a `200` response schema or + * `$.components.schemas.PageEnvelope`. + * + * The options object is a "spec" node. A spec node may contain: + * requiredProperties {string[]} names that must appear in schema.required. + * forbiddenProperties {string[]} names that must not be declared. + * properties {object} map of name -> child spec node; each named property + * must be declared, and is validated by its child spec. + * type {string} schema.type must equal this (array-typed `type` ok). + * const {any} schema.const must deep-equal this. + * enum {any[]} schema.enum must equal this as a set. + * items {object} for arrays: schema.items validated by this child spec. + * + * One level of top-level `allOf` composition is merged when reading + * required/properties (documented limitation: deeper allOf nesting is not). + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} options - the root spec node. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function envelopeShape(targetVal, options, context) { + if (!isObject(targetVal) || !isObject(options)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + matchNode(targetVal, options, base, results, 0); + return results.length ? results : undefined; +} + +/** Merge a schema's own required/properties with one level of allOf branches. */ +function effective(schema) { + const required = new Set(asArray(schema.required).filter((s) => typeof s === 'string')); + const properties = isObject(schema.properties) ? { ...schema.properties } : {}; + if (Array.isArray(schema.allOf)) { + for (const branch of schema.allOf) { + if (!isObject(branch)) continue; + for (const r of asArray(branch.required)) if (typeof r === 'string') required.add(r); + if (isObject(branch.properties)) { + for (const [k, v] of Object.entries(branch.properties)) if (!(k in properties)) properties[k] = v; + } + } + } + return { required, properties }; +} + +function typeMatches(schemaType, wanted) { + if (Array.isArray(schemaType)) return schemaType.includes(wanted); + return schemaType === wanted; +} + +function matchNode(schema, spec, path, results, depth) { + if (depth > 50 || !isObject(schema) || !isObject(spec)) return; + + if (spec.type !== undefined && !typeMatches(schema.type, spec.type)) { + results.push({ message: `schema must declare type "${spec.type}"`, path }); + } + if ('const' in spec && JSON.stringify(schema.const) !== JSON.stringify(spec.const)) { + results.push({ message: `schema must declare const ${JSON.stringify(spec.const)}`, path }); + } + if (spec.enum !== undefined) { + const have = new Set(asArray(schema.enum).map((v) => JSON.stringify(v))); + const want = new Set(asArray(spec.enum).map((v) => JSON.stringify(v))); + const equal = have.size === want.size && [...want].every((v) => have.has(v)); + if (!equal) { + results.push({ message: `schema enum must be exactly ${JSON.stringify(spec.enum)}`, path }); + } + } + + const eff = effective(schema); + + for (const name of asArray(spec.requiredProperties)) { + if (!eff.required.has(name)) { + results.push({ message: `schema must list "${name}" in required`, path: [...path, 'required'] }); + } + } + + for (const name of asArray(spec.forbiddenProperties)) { + if (Object.prototype.hasOwnProperty.call(eff.properties, name)) { + results.push({ message: `schema must not declare property "${name}"`, path: [...path, 'properties', name] }); + } + } + + if (isObject(spec.properties)) { + for (const [name, childSpec] of Object.entries(spec.properties)) { + const child = eff.properties[name]; + if (!isObject(child)) { + results.push({ message: `schema must declare property "${name}"`, path: [...path, 'properties'] }); + continue; + } + if (isObject(childSpec)) matchNode(child, childSpec, [...path, 'properties', name], results, depth + 1); + } + } + + if (isObject(spec.items)) { + if (!isObject(schema.items)) { + results.push({ message: 'schema must declare items', path: [...path, 'items'] }); + } else { + matchNode(schema.items, spec.items, [...path, 'items'], results, depth + 1); + } + } +} diff --git a/api-design-guide/linter/functions/extensionShape.js b/api-design-guide/linter/functions/extensionShape.js new file mode 100644 index 0000000..d71096d --- /dev/null +++ b/api-design-guide/linter/functions/extensionShape.js @@ -0,0 +1,89 @@ +import { isObject, toRegExp, SEMVER_PATTERN } from './lib/util.js'; + +/** + * extensionShape — validate the presence and shape of an `x-govstack-*` + * extension on a container object (operation, info, message, …). Drives the + * §18.7 deprecation metadata and §20.3 guide metadata. + * + * `given` should select the container that carries the extension (e.g. an + * operation, or `$.info`). + * + * options: + * extension {string} the extension key to validate, e.g. "x-govstack-api-guide". + * required {boolean} default true — absent extension is a violation. + * valueType {"string"|"object"|"array"} expected type of the value. + * enum {any[]} for string values: allowed values. + * requiredKeys {string[]} for object values: keys that must be present. + * semverKeys {string[]} for object values: keys whose value must be SemVer. + * keyEnums {object} { key: [allowed values] } for object values. + * keyPatterns {object} { key: regexString } for object values. + * + * @param {unknown} targetVal - the container object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function extensionShape(targetVal, options, context) { + if (!isObject(targetVal) || !isObject(options)) return; + const key = options.extension; + if (typeof key !== 'string' || !key) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const here = [...base, key]; + const results = []; + const present = Object.prototype.hasOwnProperty.call(targetVal, key); + + if (!present) { + if (options.required !== false) results.push({ message: `must declare "${key}"`, path: [...base] }); + return results.length ? results : undefined; + } + + const value = targetVal[key]; + + if (options.valueType !== undefined) { + const actual = Array.isArray(value) ? 'array' : typeof value; + const wanted = options.valueType === 'object' && Array.isArray(value) ? 'array' : options.valueType; + const ok = + (wanted === 'object' && isObject(value)) || + (wanted === 'array' && Array.isArray(value)) || + (wanted !== 'object' && wanted !== 'array' && actual === wanted); + if (!ok) { + results.push({ message: `"${key}" must be of type ${options.valueType}`, path: here }); + return results; // shape checks below assume the right type + } + } + + if (Array.isArray(options.enum) && !options.enum.includes(value)) { + results.push({ message: `"${key}" must be one of ${JSON.stringify(options.enum)}`, path: here }); + } + + if (isObject(value)) { + for (const k of Array.isArray(options.requiredKeys) ? options.requiredKeys : []) { + if (!Object.prototype.hasOwnProperty.call(value, k)) { + results.push({ message: `"${key}" must declare "${k}"`, path: here }); + } + } + const semverRe = toRegExp(SEMVER_PATTERN); + for (const k of Array.isArray(options.semverKeys) ? options.semverKeys : []) { + if (k in value && !(typeof value[k] === 'string' && semverRe.test(value[k]))) { + results.push({ message: `"${key}.${k}" must be a SemVer string`, path: [...here, k] }); + } + } + if (isObject(options.keyEnums)) { + for (const [k, allowed] of Object.entries(options.keyEnums)) { + if (k in value && Array.isArray(allowed) && !allowed.includes(value[k])) { + results.push({ message: `"${key}.${k}" must be one of ${JSON.stringify(allowed)}`, path: [...here, k] }); + } + } + } + if (isObject(options.keyPatterns)) { + for (const [k, pat] of Object.entries(options.keyPatterns)) { + const re = toRegExp(pat); + if (re && k in value && !(typeof value[k] === 'string' && re.test(value[k]))) { + results.push({ message: `"${key}.${k}" must match ${re.toString()}`, path: [...here, k] }); + } + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/lib/casing.js b/api-design-guide/linter/functions/lib/casing.js new file mode 100644 index 0000000..97e6bf2 --- /dev/null +++ b/api-design-guide/linter/functions/lib/casing.js @@ -0,0 +1,36 @@ +/** + * Casing predicates shared by the GovStack Spectral functions. + * + * Not a Spectral function; imported by function modules only. + */ + +const PATTERNS = { + // getThing, listUsers, pageSize (lower camel; digits allowed after first char) + camel: /^[a-z][a-zA-Z0-9]*$/, + // GetThing, ProblemDetails + pascal: /^[A-Z][a-zA-Z0-9]*$/, + // account-holders, merge-patch (lower kebab; digits allowed) + kebab: /^[a-z0-9]+(?:-[a-z0-9]+)*$/, + // account_holder + snake: /^[a-z0-9]+(?:_[a-z0-9]+)*$/, + // ACCOUNT_CLOSED, PENDING + screamingSnake: /^[A-Z0-9]+(?:_[A-Z0-9]+)*$/, + // alllowercase + flat: /^[a-z][a-z0-9]*$/, +}; + +/** The casing type names this module understands. */ +export const CASING_TYPES = Object.keys(PATTERNS); + +/** + * Does `name` conform to the given casing type? + * Unknown types return false (fail closed); non-string input returns false. + * @param {string} name + * @param {keyof typeof PATTERNS} type + * @returns {boolean} + */ +export function matchesCasing(name, type) { + if (typeof name !== 'string') return false; + const re = PATTERNS[type]; + return re ? re.test(name) : false; +} diff --git a/api-design-guide/linter/functions/lib/schemaWalk.js b/api-design-guide/linter/functions/lib/schemaWalk.js new file mode 100644 index 0000000..4546e39 --- /dev/null +++ b/api-design-guide/linter/functions/lib/schemaWalk.js @@ -0,0 +1,90 @@ +/** + * Cycle-safe recursive JSON Schema walker (Draft 2020-12 aware, tolerant of + * older drafts). Shared internal for the schema-oriented GovStack functions. + * + * Not a Spectral function; imported by function modules only. + * + * Spectral resolves `$ref`s before a rule runs (unless `resolved: false`), so a + * recursive schema (`Person.friend -> Person`) becomes a genuine circular JS + * object. The walker guards against this with a WeakSet keyed on node identity: + * every distinct schema object is visited at most once, at the first path it is + * reached by. That also de-duplicates schemas shared across the document. + */ + +import { isObject } from './util.js'; + +// keyword -> single subschema +const SUBSCHEMA_KEYS = [ + 'additionalProperties', + 'unevaluatedProperties', + 'additionalItems', + 'unevaluatedItems', + 'contains', + 'not', + 'if', + 'then', + 'else', + 'propertyNames', +]; +// keyword -> { name: subschema } +const MAP_KEYS = ['properties', 'patternProperties', 'dependentSchemas', '$defs', 'definitions']; +// keyword -> [ subschema ] +const ARRAY_KEYS = ['allOf', 'anyOf', 'oneOf', 'prefixItems']; + +/** + * Visit every subschema of `root`, including `root` itself. + * @param {unknown} root - a JSON Schema object (anything else is a no-op). + * @param {(node: object, path: (string|number)[]) => void} visit + * Called once per schema node. `path` is relative to `root`. + * @param {{ maxDepth?: number }} [opts] + */ +export function walkSchema(root, visit, opts = {}) { + if (!isObject(root) || typeof visit !== 'function') return; + const seen = new WeakSet(); + const maxDepth = Number.isInteger(opts.maxDepth) ? opts.maxDepth : 200; + + const recur = (node, path, depth) => { + if (!isObject(node) || seen.has(node) || depth > maxDepth) return; + seen.add(node); + + visit(node, path); + + for (const key of MAP_KEYS) { + const m = node[key]; + if (isObject(m)) { + for (const name of Object.keys(m)) recur(m[name], [...path, key, name], depth + 1); + } + } + for (const key of ARRAY_KEYS) { + const a = node[key]; + if (Array.isArray(a)) a.forEach((sub, i) => recur(sub, [...path, key, i], depth + 1)); + } + for (const key of SUBSCHEMA_KEYS) { + if (isObject(node[key])) recur(node[key], [...path, key], depth + 1); + } + const items = node.items; + if (Array.isArray(items)) { + items.forEach((sub, i) => recur(sub, [...path, 'items', i], depth + 1)); + } else if (isObject(items)) { + recur(items, [...path, 'items'], depth + 1); + } + }; + + recur(root, [], 0); +} + +/** + * Visit every declared property across all subschemas of `root`. + * @param {unknown} root + * @param {(name: string, propSchema: unknown, path: (string|number)[]) => void} cb + * `path` points at the property schema (…/properties/). + */ +export function forEachProperty(root, cb) { + if (typeof cb !== 'function') return; + walkSchema(root, (node, path) => { + const props = node.properties; + if (isObject(props)) { + for (const name of Object.keys(props)) cb(name, props[name], [...path, 'properties', name]); + } + }); +} diff --git a/api-design-guide/linter/functions/lib/standardEndpoints.js b/api-design-guide/linter/functions/lib/standardEndpoints.js new file mode 100644 index 0000000..2ea2335 --- /dev/null +++ b/api-design-guide/linter/functions/lib/standardEndpoints.js @@ -0,0 +1,26 @@ +/** + * The guide §5.10 closed set of standard unversioned endpoints: paths whose + * location or spelling is fixed by something other than this guide, and which + * are therefore exempt from §5.1 (version prefix), §5.3 (kebab-case segments), + * and the §12 collection rules. + * + * Kept in one place because four rulesets need the same answer; changing the + * set means changing guide §5.10 first. + */ + +// Exact path keys: the §5.9 operational endpoints and the conventional runtime +// specification-discovery endpoints. +const EXACT = new Set(['/health', '/ready', '/openapi.json', '/asyncapi.json']); + +// RFC 8615 roots every well-known URI at this exact prefix, so a well-known +// path cannot be moved under /v{N}/ without ceasing to be one. +const WELL_KNOWN_PREFIX = '/.well-known/'; + +/** + * @param {unknown} pathKey - an OpenAPI `paths` key. + * @returns {boolean} true when the path is in the guide §5.10 set. + */ +export function isStandardUnversionedPath(pathKey) { + if (typeof pathKey !== 'string') return false; + return EXACT.has(pathKey) || pathKey.startsWith(WELL_KNOWN_PREFIX); +} diff --git a/api-design-guide/linter/functions/lib/util.js b/api-design-guide/linter/functions/lib/util.js new file mode 100644 index 0000000..679ff60 --- /dev/null +++ b/api-design-guide/linter/functions/lib/util.js @@ -0,0 +1,73 @@ +/** + * Small shared helpers for the GovStack Spectral custom functions. + * + * These live in functions/lib/ and are NOT Spectral functions themselves. + * Spectral only loads the modules named in a ruleset's `functions:` array, so + * files in lib/ are safe to keep next to the function modules: they are only + * ever pulled in via relative `import` from a function module, and the bundler + * resolves them. + */ + +/** True for a plain (non-null, non-array) object. */ +export function isObject(v) { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** True for a non-empty string. */ +export function isNonEmptyString(v) { + return typeof v === 'string' && v.length > 0; +} + +/** Coerce to array: array stays, undefined/null -> [], scalar -> [scalar]. */ +export function asArray(v) { + if (Array.isArray(v)) return v; + if (v === undefined || v === null) return []; + return [v]; +} + +/** + * Build a RegExp from a string pattern (or return an existing RegExp). + * Returns undefined on invalid input rather than throwing, so callers can + * bail defensively. + * @param {string|RegExp} pattern + * @param {string} [flags] + * @returns {RegExp|undefined} + */ +export function toRegExp(pattern, flags) { + if (pattern instanceof RegExp) return pattern; + if (typeof pattern !== 'string' || pattern.length === 0) return undefined; + try { + return new RegExp(pattern, flags); + } catch { + return undefined; + } +} + +/** + * Expand an HTTP status matcher into a predicate over status-code keys. + * Accepts: + * - "2xx" / "4XX" / "1xx" -> class match on the leading digit + * - "default" -> matches the literal "default" response key + * - an exact code "201" -> exact match + * - any other string -> treated as an anchored regex + * @param {string} matcher + * @returns {(statusKey: string) => boolean} + */ +export function statusMatcher(matcher) { + if (typeof matcher !== 'string' || matcher.length === 0) return () => false; + const m = matcher.toLowerCase(); + if (/^[1-5]xx$/.test(m)) { + const cls = m[0]; + return (key) => typeof key === 'string' && key[0] === cls && /^\d{3}$/.test(key); + } + if (m === 'default') return (key) => key === 'default'; + if (/^\d{3}$/.test(m)) return (key) => key === m; + const re = toRegExp(`^(?:${matcher})$`); + return re ? (key) => typeof key === 'string' && re.test(key) : () => false; +} + +/** SemVer 2.0.0 core + optional pre-release/build, as used across the guide. */ +export const SEMVER_PATTERN = + '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)' + + '(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?' + + '(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$'; diff --git a/api-design-guide/linter/functions/mediaTypeExpected.js b/api-design-guide/linter/functions/mediaTypeExpected.js new file mode 100644 index 0000000..e35cf9b --- /dev/null +++ b/api-design-guide/linter/functions/mediaTypeExpected.js @@ -0,0 +1,56 @@ +import { isObject, asArray, toRegExp } from './lib/util.js'; + +/** + * mediaTypeExpected — assert a `content` map declares / forbids media types. + * Drives §6.4 (PATCH -> merge-patch+json), §9.1 (json responses) and §11.1 + * (problem+json on errors). + * + * `given` should select a `content` object, e.g. + * `$.paths[*][*].requestBody.content` or a specific response's `content`. + * + * Media types are matched as regexes against the content-map keys, so + * `application/problem+json` works verbatim (the `+` is escaped for you only if + * you pass a plain string with no regex metacharacters — otherwise pass a valid + * regex). Prefer anchored, escaped patterns. + * + * options: + * require {string|string[]} each pattern must match >=1 declared media type. + * requireOneOf {string|string[]} at least one pattern must match. + * forbid {string|string[]} no declared media type may match any pattern. + * + * @param {unknown} targetVal - a content object (map mediaType -> mediaTypeObject). + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function mediaTypeExpected(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const keys = Object.keys(targetVal); + const results = []; + + const test = (pattern, key) => { + const re = toRegExp(pattern); + return re ? re.test(key) : false; + }; + + for (const pattern of asArray(opts.require)) { + if (!keys.some((k) => test(pattern, k))) { + results.push({ message: `content must declare a media type matching "${pattern}"`, path: [...base] }); + } + } + + const oneOf = asArray(opts.requireOneOf); + if (oneOf.length && !oneOf.some((p) => keys.some((k) => test(p, k)))) { + results.push({ message: `content must declare one of these media types: ${oneOf.join(', ')}`, path: [...base] }); + } + + for (const pattern of asArray(opts.forbid)) { + for (const key of keys.filter((k) => test(pattern, k))) { + results.push({ message: `content must not declare media type "${key}"`, path: [...base, key] }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/operationResponses.js b/api-design-guide/linter/functions/operationResponses.js new file mode 100644 index 0000000..871547d --- /dev/null +++ b/api-design-guide/linter/functions/operationResponses.js @@ -0,0 +1,83 @@ +import { isObject, asArray, statusMatcher } from './lib/util.js'; + +const isSuccess = (key) => typeof key === 'string' && key[0] === '2' && /^\d{3}$/.test(key); + +/** + * operationResponses — assert an operation's declared response set satisfies + * presence / absence / count constraints. + * + * `given` should select an operation object, e.g. + * `$.paths[*][get,put,post,delete,patch]`. The function reads `.responses`. + * + * options (all optional; combine as needed): + * require {string|string[]} each matcher MUST match >=1 declared status. + * requireOneOf {string|string[]} at least one matcher must match. + * forbid {string|string[]} no declared status may match any matcher. + * minCount {number} at least this many response entries. + * minNonSuccess {number} at least this many non-2xx responses. + * + * @param {unknown} targetVal - an operation object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function operationResponses(targetVal, options, context) { + if (!isObject(targetVal)) return; + const responses = targetVal.responses; + if (!isObject(responses)) { + // No responses object at all is itself a violation of any presence check. + if (options && (options.require || options.requireOneOf || options.minCount)) { + const base = context && Array.isArray(context.path) ? context.path : []; + return [{ message: 'operation declares no responses', path: [...base] }]; + } + return; + } + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const keys = Object.keys(responses); + const results = []; + + for (const pattern of asArray(opts.require)) { + const m = statusMatcher(pattern); + if (!keys.some(m)) { + results.push({ message: `operation must declare a "${pattern}" response`, path: [...base, 'responses'] }); + } + } + + const oneOf = asArray(opts.requireOneOf); + if (oneOf.length) { + const ok = oneOf.some((pattern) => keys.some(statusMatcher(pattern))); + if (!ok) { + results.push({ + message: `operation must declare at least one of these responses: ${oneOf.join(', ')}`, + path: [...base, 'responses'], + }); + } + } + + for (const pattern of asArray(opts.forbid)) { + const m = statusMatcher(pattern); + for (const key of keys.filter(m)) { + results.push({ message: `operation must not declare a "${key}" response`, path: [...base, 'responses', key] }); + } + } + + if (Number.isInteger(opts.minCount) && keys.length < opts.minCount) { + results.push({ + message: `operation must declare at least ${opts.minCount} responses (found ${keys.length})`, + path: [...base, 'responses'], + }); + } + + if (Number.isInteger(opts.minNonSuccess)) { + const nonSuccess = keys.filter((k) => !isSuccess(k)).length; + if (nonSuccess < opts.minNonSuccess) { + results.push({ + message: `operation must declare at least ${opts.minNonSuccess} non-2xx response(s) (found ${nonSuccess})`, + path: [...base, 'responses'], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/pathSegments.js b/api-design-guide/linter/functions/pathSegments.js new file mode 100644 index 0000000..8b60b38 --- /dev/null +++ b/api-design-guide/linter/functions/pathSegments.js @@ -0,0 +1,103 @@ +import { isObject } from './lib/util.js'; +import { matchesCasing } from './lib/casing.js'; +import { isStandardUnversionedPath } from './lib/standardEndpoints.js'; + +const VERSION_SEG = /^v\d+$/; +const isParam = (seg) => seg.startsWith('{') && seg.endsWith('}'); +const split = (key) => key.split('/').filter((s) => s.length > 0); +const MUTATING_METHODS = ['post', 'put', 'patch', 'delete']; + +/** + * pathSegments — structural checks over the OpenAPI `paths` object. One check + * per rule instance (selected by `check`), so each guide rule maps to one + * clearly-named Spectral rule. + * + * `given` should be `$.paths`. + * + * options: + * check {string} required, one of: + * "versionPrefix" every path key starts with /v{N}/ (guide 5.1) + * "segmentCasing" every non-version, non-param segment obeys `casing` + * (default kebab) (guide 5.3) + * "maxDepthAfterVersion" at most `max` NON-PARAM levels follow the version + * prefix; `{param}` segments do NOT count as levels + * (default max 2) (guide 5.4) + * "standardEndpointsReadOnly" the guide 5.10 standard unversioned endpoints + * declare no mutating method (guide 5.10) + * casing {string} for "segmentCasing" (default "kebab"). + * max {number} for "maxDepthAfterVersion" (default 2). + * exemptPaths {string[]} for "versionPrefix": additional path keys matched + * EXACTLY that are exempt from the version-prefix + * requirement, on top of the guide 5.10 set. + * + * "versionPrefix" and "segmentCasing" both skip the guide 5.10 standard + * unversioned endpoints, whose location and spelling are fixed elsewhere. + * + * @param {unknown} targetVal - the paths object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function pathSegments(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const check = opts.check; + const results = []; + + for (const key of Object.keys(targetVal)) { + if (typeof key !== 'string' || !key.startsWith('/')) continue; + const segs = split(key); + const here = [...base, key]; + + // Guide 5.10 fixes the location and spelling of a closed set of endpoints + // elsewhere (RFC 8615 well-known URIs, the 5.9 operational endpoints, and + // runtime specification discovery), so 5.1 and 5.3 must not fire on them. + if (isStandardUnversionedPath(key) && (check === 'versionPrefix' || check === 'segmentCasing')) continue; + + if (check === 'versionPrefix') { + const exemptPaths = Array.isArray(opts.exemptPaths) ? opts.exemptPaths : []; + if (exemptPaths.includes(key)) continue; + if (segs.length === 0 || !VERSION_SEG.test(segs[0])) { + results.push({ message: `path "${key}" must start with a version prefix (/v{N}/…)`, path: here }); + } + } else if (check === 'segmentCasing') { + const casing = typeof opts.casing === 'string' ? opts.casing : 'kebab'; + for (const seg of segs) { + if (VERSION_SEG.test(seg) || isParam(seg)) continue; + if (!matchesCasing(seg, casing)) { + results.push({ message: `path "${key}" segment "${seg}" must be ${casing} case`, path: here }); + } + } + } else if (check === 'standardEndpointsReadOnly') { + if (!isStandardUnversionedPath(key)) continue; + const item = targetVal[key]; + if (!isObject(item)) continue; + for (const method of MUTATING_METHODS) { + if (method in item) { + results.push({ + message: `path "${key}" is a standard unversioned endpoint (guide 5.10) and must not declare "${method}"; a business resource belongs on the versioned surface`, + path: [...here, method], + }); + } + } + } else if (check === 'maxDepthAfterVersion') { + const max = Number.isInteger(opts.max) ? opts.max : 2; + const afterVersion = VERSION_SEG.test(segs[0]) ? segs.slice(1) : segs; + // "Levels of nesting" counts resource/action segments only: a `{param}` + // path parameter is NOT a level. Guide 5.8/15.5/16.11 mandate paths like + // /v1/operations/{operationId}/cancel and + // /v1/subscriptions/{subscriptionId}/rotate-secret — three raw segments, + // but only two non-param levels — so params must not count toward depth. + const levels = afterVersion.filter((seg) => !isParam(seg)); + if (levels.length > max) { + results.push({ + message: `path "${key}" has ${levels.length} levels of nesting after the version prefix (max ${max}; path parameters do not count)`, + path: here, + }); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/responseHeaderRequired.js b/api-design-guide/linter/functions/responseHeaderRequired.js new file mode 100644 index 0000000..bdfb52f --- /dev/null +++ b/api-design-guide/linter/functions/responseHeaderRequired.js @@ -0,0 +1,71 @@ +import { isObject, asArray, statusMatcher } from './lib/util.js'; + +/** + * responseHeaderRequired — assert that responses whose status matches a pattern + * declare one or more headers, and (optionally) that a companion status exists. + * + * `given` should select an operation's `responses` object, e.g. + * `$.paths[*][get,put,post,delete,patch].responses`. + * + * options: + * status {string} status matcher: "201" | "2xx" | "default" | regex. + * headers {string|string[]} header name(s) each matching response MUST + * declare (compared case-insensitively by default). + * caseInsensitive{boolean} default true. + * requireStatus {boolean} if true, at least one response must match `status` + * (otherwise the rule only constrains matches that exist). + * alsoRequireStatus {string} a companion status matcher that MUST also be + * present in the responses object (e.g. a 304 alongside ETag). + * + * @param {unknown} targetVal - a responses object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function responseHeaderRequired(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const wanted = asArray(opts.headers).filter((h) => typeof h === 'string' && h.length); + const matchStatus = statusMatcher(opts.status); + const ci = opts.caseInsensitive !== false; + const results = []; + + const statusKeys = Object.keys(targetVal); + const matching = statusKeys.filter(matchStatus); + + if (opts.requireStatus === true && matching.length === 0) { + results.push({ + message: `no response declared for status "${opts.status}"`, + path: [...base], + }); + } + + for (const key of matching) { + const response = targetVal[key]; + if (!isObject(response)) continue; + const declared = isObject(response.headers) ? Object.keys(response.headers) : []; + const declaredCmp = ci ? declared.map((h) => h.toLowerCase()) : declared; + for (const header of wanted) { + const needle = ci ? header.toLowerCase() : header; + if (!declaredCmp.includes(needle)) { + results.push({ + message: `${key} response must declare a "${header}" header`, + path: [...base, key], + }); + } + } + } + + if (opts.alsoRequireStatus !== undefined) { + const companion = statusMatcher(opts.alsoRequireStatus); + if (!statusKeys.some(companion)) { + results.push({ + message: `responses must also declare a "${opts.alsoRequireStatus}" response`, + path: [...base], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s03-asyncOperation.js b/api-design-guide/linter/functions/s03-asyncOperation.js new file mode 100644 index 0000000..4d27cdb --- /dev/null +++ b/api-design-guide/linter/functions/s03-asyncOperation.js @@ -0,0 +1,115 @@ +import { isObject, isNonEmptyString } from './lib/util.js'; + +const ACTIONS = new Set(['send', 'receive']); + +/** The $ref string of a Reference Object, or undefined for anything else. */ +function refString(node) { + return isObject(node) && typeof node.$ref === 'string' ? node.$ref : undefined; +} + +/** + * Resolve a local JSON Pointer ("#/channels/foo") against the document root. + * Returns undefined for non-local refs or unresolvable pointers. + */ +function resolveLocalRef(root, ref) { + if (typeof ref !== 'string' || !ref.startsWith('#/')) return undefined; + const parts = ref + .slice(2) + .split('/') + .map((p) => p.replace(/~1/g, '/').replace(/~0/g, '~')); + let cur = root; + for (const p of parts) { + if (cur === null || typeof cur !== 'object') return undefined; + cur = cur[p]; + if (cur === undefined) return undefined; + } + return cur; +} + +/** + * s03-asyncOperation — §3.7 AsyncAPI 3.0 operation-metadata completeness. + * + * Given: the whole UNRESOLVED AsyncAPI 3.0 document (`$`, with `resolved: false` + * on the rule) so that operation `channel` / `messages` $ref pointers are still + * visible as strings. + * + * For every entry under `operations`, asserts it declares: + * - `action` == "send" or "receive" + * - a non-empty `description` + * - a `channel` Reference Object ({$ref: }) + * - a non-empty `messages` array of Reference Objects + * - each `messages[i]` $ref points into the operation's referenced channel's + * messages (ref begins "/messages/"); when the channel ref is a + * local pointer, the referenced message key must exist on that channel. + * + * The CloudEvents-ness of the referenced message is a §17.6 concern and is not + * checked here; this only verifies presence and channel-membership. + * + * options: none. + * @param {unknown} targetVal - the document root ($). + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s03AsyncOperation(targetVal, _options, context) { + const root = targetVal; + if (!isObject(root) || !isObject(root.operations)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const findings = []; + + for (const [opId, op] of Object.entries(root.operations)) { + const at = (...seg) => [...base, 'operations', opId, ...seg]; + if (!isObject(op)) { + findings.push({ message: `operation "${opId}" must be an object`, path: at() }); + continue; + } + + if (!ACTIONS.has(op.action)) { + findings.push({ message: `operation "${opId}" must declare action "send" or "receive"`, path: at('action') }); + } + if (!isNonEmptyString(op.description)) { + findings.push({ message: `operation "${opId}" must declare a non-empty description`, path: at('description') }); + } + + const channelRef = refString(op.channel); + if (!channelRef) { + findings.push({ message: `operation "${opId}" must reference a channel via $ref`, path: at('channel') }); + } + + const messages = op.messages; + if (!Array.isArray(messages) || messages.length < 1) { + findings.push({ message: `operation "${opId}" must reference at least one channel message via $ref`, path: at('messages') }); + continue; + } + + messages.forEach((m, i) => { + const mRef = refString(m); + if (!mRef) { + findings.push({ message: `operation "${opId}" message ${i} must be a $ref to a channel message`, path: at('messages', i) }); + return; + } + if (!channelRef) return; // channel error already reported; can't cross-check + const prefix = `${channelRef}/messages/`; + if (!mRef.startsWith(prefix)) { + findings.push({ + message: `operation "${opId}" message ${i} ($ref "${mRef}") must reference a message on the operation's channel "${channelRef}"`, + path: at('messages', i), + }); + return; + } + // For local channel refs, confirm the message key is actually declared. + if (channelRef.startsWith('#/')) { + const channel = resolveLocalRef(root, channelRef); + const key = mRef.slice(prefix.length); + if (isObject(channel) && (!isObject(channel.messages) || !(key in channel.messages))) { + findings.push({ + message: `operation "${opId}" message ${i} references "${key}" which is not defined on channel "${channelRef}"`, + path: at('messages', i), + }); + } + } + }); + } + + return findings.length ? findings : undefined; +} diff --git a/api-design-guide/linter/functions/s04-bodyExamplesEnums.js b/api-design-guide/linter/functions/s04-bodyExamplesEnums.js new file mode 100644 index 0000000..634775f --- /dev/null +++ b/api-design-guide/linter/functions/s04-bodyExamplesEnums.js @@ -0,0 +1,117 @@ +import { isObject } from './lib/util.js'; +import { walkSchema } from './lib/schemaWalk.js'; + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** True if a media-type object (or its schema) carries at least one example. */ +function hasExample(mediaTypeObj) { + if (!isObject(mediaTypeObj)) return false; + if ('example' in mediaTypeObj) return true; + if (isObject(mediaTypeObj.examples) && Object.keys(mediaTypeObj.examples).length > 0) return true; + const schema = mediaTypeObj.schema; + if (isObject(schema)) { + if ('example' in schema) return true; + if (Array.isArray(schema.examples) && schema.examples.length > 0) return true; + } + return false; +} + +/** Push a finding if none of a `content` map's media types carries an example. */ +function checkContent(content, path, label, results) { + if (!isObject(content)) return; + const mediaTypes = Object.keys(content); + if (mediaTypes.length === 0) return; + if (!mediaTypes.some((mt) => hasExample(content[mt]))) { + results.push({ message: `${label} must have at least one example`, path }); + } +} + +/** + * Push a finding for every `enum` schema node lacking a `description`. PROXY: + * "has a description" substitutes for "documents what the values mean" - a + * description that doesn't actually explain the enum values still passes. + */ +function checkEnums(schema, path, results) { + if (!isObject(schema)) return; + walkSchema(schema, (node, subPath) => { + if (Array.isArray(node.enum) && node.enum.length > 0 && typeof node.description !== 'string') { + results.push({ + message: `enum at ${[...path, ...subPath].join('/') || '(root)'} must document what its values mean (add a description)`, + path: [...path, ...subPath], + }); + } + }); +} + +/** + * bodyExamplesAndEnums — guide 4.2 (proxy): every request/response body (and + * AsyncAPI message) MUST have at least one example; every `enum` MUST + * document what its values mean. + * + * PROXY: the enum check substitutes "has a non-empty description" for + * "documents what the values mean" per the guide's own carve-out ("an + * example alone is insufficient when the values are not self-explanatory") - + * it cannot verify the description actually explains the values. + * + * `given` should be `$` (the root document); OpenAPI paths and AsyncAPI + * components.messages are both scanned so one rule covers either surface. + * + * @param {unknown} targetVal - the root document. + * @param {object} options - unused. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function bodyExamplesAndEnums(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + if (isObject(targetVal.paths)) { + for (const [pathKey, pathItem] of Object.entries(targetVal.paths)) { + if (!isObject(pathItem)) continue; + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (!isObject(op)) continue; + const opPath = [...base, 'paths', pathKey, method]; + + if (isObject(op.requestBody)) { + const reqContent = op.requestBody.content; + const reqPath = [...opPath, 'requestBody', 'content']; + checkContent(reqContent, reqPath, `${method.toUpperCase()} ${pathKey} request body`, results); + if (isObject(reqContent)) { + for (const mt of Object.keys(reqContent)) { + if (isObject(reqContent[mt])) checkEnums(reqContent[mt].schema, [...reqPath, mt, 'schema'], results); + } + } + } + + if (isObject(op.responses)) { + for (const [status, response] of Object.entries(op.responses)) { + if (!isObject(response)) continue; + const respPath = [...opPath, 'responses', status, 'content']; + checkContent(response.content, respPath, `${method.toUpperCase()} ${pathKey} ${status} response body`, results); + if (isObject(response.content)) { + for (const mt of Object.keys(response.content)) { + if (isObject(response.content[mt])) checkEnums(response.content[mt].schema, [...respPath, mt, 'schema'], results); + } + } + } + } + } + } + } + + if (isObject(targetVal.components) && isObject(targetVal.components.messages)) { + for (const [name, message] of Object.entries(targetVal.components.messages)) { + if (!isObject(message)) continue; + const msgPath = [...base, 'components', 'messages', name]; + const examples = message.examples; + if (!Array.isArray(examples) || examples.length === 0) { + results.push({ message: `message "${name}" must have at least one example`, path: msgPath }); + } + checkEnums(message.payload, [...msgPath, 'payload'], results); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s04-duplicateDescriptions.js b/api-design-guide/linter/functions/s04-duplicateDescriptions.js new file mode 100644 index 0000000..bfb6165 --- /dev/null +++ b/api-design-guide/linter/functions/s04-duplicateDescriptions.js @@ -0,0 +1,70 @@ +import { isObject } from './lib/util.js'; + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** + * duplicateOperationDescriptions — STRICT-ONLY heuristic proxy for guide 4.4 + * ("operation `description` MUST describe what the operation actually + * does"). Flags operation `description` strings that are byte-identical + * (after trimming) across two or more distinct operations: a strong signal + * of copy-paste that was never edited to match the operation it landed in + * (the guide cites this exact failure mode). Does NOT verify that a + * non-duplicated description is actually accurate for its operation - that + * requires reading and understanding the operation's real behaviour. + * + * `given` should be `$` (the root document); both OpenAPI `paths` operations + * and AsyncAPI `operations` are scanned so one rule covers either surface. + * + * @param {unknown} targetVal - the root document. + * @param {object} options - unused. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function duplicateOperationDescriptions(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const seen = new Map(); // trimmed description text -> [{ path, label }] + + const record = (description, path, label) => { + if (typeof description !== 'string' || description.trim().length === 0) return; + const key = description.trim(); + if (!seen.has(key)) seen.set(key, []); + seen.get(key).push({ path, label }); + }; + + if (isObject(targetVal.paths)) { + for (const [pathKey, pathItem] of Object.entries(targetVal.paths)) { + if (!isObject(pathItem)) continue; + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (isObject(op)) { + record(op.description, [...base, 'paths', pathKey, method, 'description'], `${method.toUpperCase()} ${pathKey}`); + } + } + } + } + + if (isObject(targetVal.operations)) { + for (const [opKey, op] of Object.entries(targetVal.operations)) { + if (isObject(op)) record(op.description, [...base, 'operations', opKey, 'description'], opKey); + } + } + + const results = []; + for (const [text, occurrences] of seen) { + if (occurrences.length < 2) continue; + for (const occ of occurrences) { + const others = occurrences + .filter((o) => o !== occ) + .map((o) => o.label) + .join(', '); + const preview = text.length > 60 ? `${text.slice(0, 60)}…` : text; + results.push({ + message: `operation description is identical (copy-paste?) to ${others}: "${preview}"`, + path: occ.path, + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s04-noPlaceholderText.js b/api-design-guide/linter/functions/s04-noPlaceholderText.js new file mode 100644 index 0000000..44c7c65 --- /dev/null +++ b/api-design-guide/linter/functions/s04-noPlaceholderText.js @@ -0,0 +1,63 @@ +import { isObject } from './lib/util.js'; + +// guide 4.3: "TBD", "Lorem ipsum", and the literal placeholder list "a, b, c" +// are the three mechanically-scannable forms of placeholder text. Cross-BB +// name leakage and placeholder test-plan steps are NOT scanned: they need an +// external list of BB names / test-plan conventions this function doesn't have. +const DEFAULT_PATTERNS = [ + { name: 'TBD', regex: /\bTBD\b/ }, + { name: 'Lorem ipsum', regex: /lorem\s+ipsum/i }, + { name: 'placeholder list "a, b, c"', regex: /\ba\s*,\s*b\s*,\s*c\b/i }, +]; + +/** + * noPlaceholderText — guide 4.3 (proxy): recursively scans every string value + * in the given node for placeholder text (TBD / Lorem ipsum / "a, b, c"). + * Does NOT check for content copy-pasted from another BB with that BB's name + * still present, or test plans with literal placeholder steps - both need + * context this function doesn't have access to. + * + * `given` should be `$` (the whole document): placeholder text can appear in + * any string (descriptions, summaries, examples, titles, ...), not just + * schemas, so this walks generically rather than via the schema-keyword-aware + * walker. + * + * @param {unknown} targetVal - any value; the root document in practice. + * @param {object} options - unused. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function noPlaceholderText(targetVal, options, context) { + if (targetVal === undefined || targetVal === null) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + const seen = new WeakSet(); + + const visit = (node, path) => { + if (typeof node === 'string') { + for (const { name, regex } of DEFAULT_PATTERNS) { + if (regex.test(node)) { + results.push({ + message: `placeholder text (${name}) found: remove or replace with real content`, + path: [...base, ...path], + }); + } + } + return; + } + if (Array.isArray(node)) { + if (seen.has(node)) return; + seen.add(node); + node.forEach((child, i) => visit(child, [...path, i])); + return; + } + if (isObject(node)) { + if (seen.has(node)) return; + seen.add(node); + for (const key of Object.keys(node)) visit(node[key], [...path, key]); + } + }; + + visit(targetVal, []); + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s05-actionVerbs.js b/api-design-guide/linter/functions/s05-actionVerbs.js new file mode 100644 index 0000000..4b1c520 --- /dev/null +++ b/api-design-guide/linter/functions/s05-actionVerbs.js @@ -0,0 +1,70 @@ +import { isObject } from './lib/util.js'; + +const VERSION_SEG = /^v\d+$/; +const isParam = (seg) => seg.startsWith('{') && seg.endsWith('}'); +const split = (key) => key.split('/').filter((s) => s.length > 0); + +// Lexical dictionary of common CRUD-mirroring / domain action verbs. No +// morphology: this both under- and over-fires (see JSDoc below). +const DEFAULT_VERBS = [ + 'get', 'create', 'new', 'add', 'update', 'edit', 'modify', 'delete', 'remove', + 'fetch', 'retrieve', 'list', 'find', 'set', 'cancel', 'approve', 'reject', + 'submit', 'publish', 'activate', 'deactivate', 'archive', 'restore', 'confirm', + 'verify', 'validate', 'process', 'execute', 'start', 'stop', 'pause', 'resume', + 'complete', 'close', 'reopen', 'send', 'search', +]; + +/** + * actionVerbSegment — STRICT-ONLY heuristic: flags a path whose final, + * non-parameter segment is a verb (from a fixed lexical list) that is NOT + * immediately preceded by a `{param}` segment. + * + * Drives both guide 5.7 ("verbs MUST NOT appear in CRUD paths") and 5.8 + * ("non-CRUD actions MUST be sub-resources, i.e. /{collection}/{id}/{verb}") + * — the guide states the same shape requirement from two directions + * (prohibition vs. required positive shape), so both rules share this one + * detector and differ only in id/message/severity/docs. + * + * Purely lexical: it does not know which paths are "CRUD operations" vs. + * legitimate collection names that happen to end in a dictionary verb (e.g. + * a segment literally named "process" as a noun), so it both over- and + * under-fires. `verbs` lets callers extend the default list. + * + * Given: $.paths + * Options: verbs {string[]} additional verbs merged into the default list. + * + * @param {unknown} targetVal - the paths object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function actionVerbSegment(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const verbs = new Set([ + ...DEFAULT_VERBS, + ...(Array.isArray(opts.verbs) ? opts.verbs.map((v) => String(v).toLowerCase()) : []), + ]); + const results = []; + + for (const key of Object.keys(targetVal)) { + if (typeof key !== 'string' || !key.startsWith('/')) continue; + const segs = split(key); + if (segs.length === 0) continue; + const last = segs[segs.length - 1]; + if (isParam(last) || VERSION_SEG.test(last)) continue; + const words = last.split('-'); + const verbLike = words.some((w) => verbs.has(w.toLowerCase())); + if (!verbLike) continue; + const prev = segs.length >= 2 ? segs[segs.length - 2] : undefined; + if (!isParam(prev)) { + results.push({ + message: `path "${key}" ends in a verb-like segment "${last}" without a preceding {id} sub-resource`, + path: [...base, key], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s05-pluralNoun.js b/api-design-guide/linter/functions/s05-pluralNoun.js new file mode 100644 index 0000000..3922315 --- /dev/null +++ b/api-design-guide/linter/functions/s05-pluralNoun.js @@ -0,0 +1,63 @@ +import { isObject } from './lib/util.js'; + +const VERSION_SEG = /^v\d+$/; +const isParam = (seg) => seg.startsWith('{') && seg.endsWith('}'); +const split = (key) => key.split('/').filter((s) => s.length > 0); + +/** + * pluralSegment — STRICT-ONLY heuristic: every non-version, non-parameter + * path segment should "look plural" (guide 5.2). Purely lexical: a segment + * looks plural if its last kebab-case word ends in "s", or the whole segment + * is listed in `irregularPlurals`. `exceptions` allow-lists literal segment + * names that other guide rules already sanction as singular (e.g. + * "health"/"ready" per 5.9, action-verb leaves per 5.8) so callers can quiet + * expected non-noise. + * + * This has no morphological understanding of English: it accepts non-plural + * words that happen to end in "s" (e.g. "status") and flags legitimate + * singular segments not covered by `exceptions`/`irregularPlurals`. + * + * Given: $.paths + * Options: + * exceptions {string[]} literal segment names (case-insensitive) allowed + * to stay singular. + * irregularPlurals {string[]} extra accepted plural forms not ending in + * "s" (e.g. "children", "people"). + * + * @param {unknown} targetVal - the paths object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function pluralSegment(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const exceptions = new Set( + (Array.isArray(opts.exceptions) ? opts.exceptions : []).map((s) => String(s).toLowerCase()), + ); + const irregular = new Set( + (Array.isArray(opts.irregularPlurals) ? opts.irregularPlurals : []).map((s) => String(s).toLowerCase()), + ); + const results = []; + + for (const key of Object.keys(targetVal)) { + if (typeof key !== 'string' || !key.startsWith('/')) continue; + const segs = split(key); + for (const seg of segs) { + if (isParam(seg) || VERSION_SEG.test(seg)) continue; + const lower = seg.toLowerCase(); + if (exceptions.has(lower) || irregular.has(lower)) continue; + const words = seg.split('-'); + const lastWord = words[words.length - 1]; + if (!lastWord.toLowerCase().endsWith('s')) { + results.push({ + message: `path "${key}" segment "${seg}" should be a plural noun`, + path: [...base, key], + }); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s06-bulkMutationSelection.js b/api-design-guide/linter/functions/s06-bulkMutationSelection.js new file mode 100644 index 0000000..f98b6ae --- /dev/null +++ b/api-design-guide/linter/functions/s06-bulkMutationSelection.js @@ -0,0 +1,42 @@ +import { isObject, asArray } from './lib/util.js'; + +const MUTATING_METHODS = ['put', 'patch', 'delete']; + +/** + * bulkMutationSelection — guide 6.7 (proxy): a collection-targeted PUT, + * PATCH, or DELETE (a path with no `{param}` segment) MUST require at least + * one explicit selection parameter. PROXY: only checks that >=1 `query` + * parameter is declared (path-item-level or operation-level); it cannot + * verify the parameter actually scopes/limits which records are mutated, nor + * does it check the append-only-resource carve-out (that needs external + * knowledge of which resources are designated append-only). + * + * `given` should select path-item objects already filtered to collection-only + * keys, e.g. `$.paths[?(!@property.includes('{'))]`. + * + * @param {unknown} targetVal - a path-item object. + * @param {object} options - unused. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function bulkMutationSelection(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const pathItemParams = asArray(targetVal.parameters); + const results = []; + + for (const method of MUTATING_METHODS) { + const op = targetVal[method]; + if (!isObject(op)) continue; + const params = [...pathItemParams, ...asArray(op.parameters)]; + const hasQueryParam = params.some((p) => isObject(p) && p.in === 'query'); + if (!hasQueryParam) { + results.push({ + message: `bulk ${method.toUpperCase()} on a collection must declare at least one query parameter for explicit selection`, + path: [...base, method], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s07-baselineResponses.js b/api-design-guide/linter/functions/s07-baselineResponses.js new file mode 100644 index 0000000..2dc2b93 --- /dev/null +++ b/api-design-guide/linter/functions/s07-baselineResponses.js @@ -0,0 +1,50 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** Deterministic baseline errors whose applicability is visible in OAS. */ +export default function baselineResponses(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const rootSecurity = Array.isArray(targetVal.security) ? targetVal.security : null; + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + const results = []; + + for (const [pathKey, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + for (const method of METHODS) { + const operation = pathItem[method]; + if (!isObject(operation)) continue; + const responses = isObject(operation.responses) ? operation.responses : {}; + const required = new Set(); + const effectiveSecurity = Array.isArray(operation.security) ? operation.security : rootSecurity; + if (Array.isArray(effectiveSecurity) && effectiveSecurity.length > 0) { + required.add('401'); + const hasScopes = effectiveSecurity.some( + (requirement) => + isObject(requirement) && + Object.values(requirement).some((scopes) => Array.isArray(scopes) && scopes.length > 0), + ); + if (hasScopes) required.add('403'); + } + if (isObject(operation.requestBody)) required.add('400'); + const parameters = [ + ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []), + ...(Array.isArray(operation.parameters) ? operation.parameters : []), + ]; + if (parameters.some((parameter) => isObject(parameter) && ['path', 'query'].includes(parameter.in))) { + required.add('400'); + } + if (/\{[^}]+\}/.test(pathKey)) required.add('404'); + for (const status of required) { + if (!Object.prototype.hasOwnProperty.call(responses, status)) { + results.push({ + message: `${method.toUpperCase()} ${pathKey} should declare applicable baseline response ${status}`, + path: [...base, 'paths', pathKey, method, 'responses'], + }); + } + } + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s07-creationResponses.js b/api-design-guide/linter/functions/s07-creationResponses.js new file mode 100644 index 0000000..7f2d87d --- /dev/null +++ b/api-design-guide/linter/functions/s07-creationResponses.js @@ -0,0 +1,25 @@ +import { isObject } from './lib/util.js'; + +const CREATE_OPERATION_RE = /^(create|register|add|submit|start|initiate)[A-Z0-9_]/; + +/** Advisory proxy: operationIds that clearly describe creation should expose + * either synchronous 201 or asynchronous 202 semantics. */ +export default function creationResponses(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + const results = []; + for (const [pathKey, pathItem] of Object.entries(paths)) { + const operation = isObject(pathItem) ? pathItem.post : null; + if (!isObject(operation) || typeof operation.operationId !== 'string') continue; + if (!CREATE_OPERATION_RE.test(operation.operationId)) continue; + const responses = isObject(operation.responses) ? operation.responses : {}; + if (!Object.prototype.hasOwnProperty.call(responses, '201') && !Object.prototype.hasOwnProperty.call(responses, '202')) { + results.push({ + message: `creation-like POST ${pathKey} (${operation.operationId}) should declare a 201 or 202 response`, + path: [...base, 'paths', pathKey, 'post', 'responses'], + }); + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s07-noStoreOnProblemJson.js b/api-design-guide/linter/functions/s07-noStoreOnProblemJson.js new file mode 100644 index 0000000..e6fc70d --- /dev/null +++ b/api-design-guide/linter/functions/s07-noStoreOnProblemJson.js @@ -0,0 +1,37 @@ +import { isObject } from './lib/util.js'; + +/** + * s07-noStoreOnProblemJson — assert that a response using + * `application/problem+json` content declares a `Cache-Control` header + * (guide 7.20). Presence-only: does not check that the header's value is + * actually `no-store`, and does not attempt to identify "Operation-status + * responses" (§15), which have no content-type signature to key off. + * + * `given` should select individual response objects, e.g. + * `$.paths[*][get,put,post,delete,patch].responses[*]`. + * + * @param {unknown} targetVal - a response object. + * @param {object} [options] - unused. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s07NoStoreOnProblemJson(targetVal, options, context) { + if (!isObject(targetVal)) return; + const content = targetVal.content; + if (!isObject(content)) return; + + const isProblemJson = Object.keys(content).some((key) => /^application\/problem\+json/i.test(key)); + if (!isProblemJson) return; + + const declared = isObject(targetVal.headers) ? Object.keys(targetVal.headers) : []; + const hasCacheControl = declared.some((h) => h.toLowerCase() === 'cache-control'); + if (hasCacheControl) return; + + const base = context && Array.isArray(context.path) ? context.path : []; + return [ + { + message: 'application/problem+json response must declare a "Cache-Control" header', + path: [...base], + }, + ]; +} diff --git a/api-design-guide/linter/functions/s07-successResponseSchema.js b/api-design-guide/linter/functions/s07-successResponseSchema.js new file mode 100644 index 0000000..4f2b529 --- /dev/null +++ b/api-design-guide/linter/functions/s07-successResponseSchema.js @@ -0,0 +1,49 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** Enforce the deterministic successful-response schema clauses of guide 7.21. */ +export default function successResponseSchema(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + for (const branch of ['paths', 'webhooks']) { + const pathItems = targetVal[branch]; + if (!isObject(pathItems)) continue; + for (const [pathKey, pathItem] of Object.entries(pathItems)) { + if (!isObject(pathItem)) continue; + for (const method of METHODS) { + const operation = pathItem[method]; + if (!isObject(operation) || !isObject(operation.responses)) continue; + for (const [status, response] of Object.entries(operation.responses)) { + if (!/^2\d\d$/.test(status) || !isObject(response) || response.content === undefined) continue; + const responsePath = [...base, branch, pathKey, method, 'responses', status, 'content']; + if (status === '204') { + results.push({ + message: `${method.toUpperCase()} ${pathKey} response 204 must not declare content`, + path: responsePath, + }); + continue; + } + if (!isObject(response.content) || Object.keys(response.content).length === 0) { + results.push({ + message: `${method.toUpperCase()} ${pathKey} response ${status} content must declare at least one media type schema`, + path: responsePath, + }); + continue; + } + for (const [mediaType, media] of Object.entries(response.content)) { + if (!isObject(media) || !isObject(media.schema) || Object.keys(media.schema).length === 0) { + results.push({ + message: `${method.toUpperCase()} ${pathKey} response ${status} media type ${mediaType} must declare a non-empty schema`, + path: [...responsePath, mediaType], + }); + } + } + } + } + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-credentialsInUrl.js b/api-design-guide/linter/functions/s08-credentialsInUrl.js new file mode 100644 index 0000000..bae3483 --- /dev/null +++ b/api-design-guide/linter/functions/s08-credentialsInUrl.js @@ -0,0 +1,74 @@ +import { isObject } from './lib/util.js'; + +const OPS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; + +// Names that look like a credential travelling somewhere other than the +// Authorization header (query/path parameters, or an apiKey security scheme +// placed in query/cookie). +const SUSPECT_NAME = /(access[_-]?)?token|api[_-]?key|apikey|secret|passwd|password|credential|auth(?:orization)?$/i; + +/** + * s08 credentialsInUrl — proxy for guide 8.1: credentials must travel in the + * Authorization header, never in query parameters, fragments, or URL paths. + * + * Checks two mechanically-visible signals: + * - no `apiKey` security scheme is placed `in: query` or `in: cookie` + * - no path/query parameter has a credential-looking name (token, apiKey, + * secret, password, credential, ...) + * + * Does NOT verify: that credentials actually travel via the Authorization + * header at runtime, arbitrary/synonym parameter names, URL fragments (not + * representable in OpenAPI), or header VALUES that might embed a credential. + * + * `given` should be the document root (`$`). + * + * @param {unknown} targetVal - the document root. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function credentialsInUrl(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const schemes = isObject(targetVal.components) ? targetVal.components.securitySchemes : undefined; + if (isObject(schemes)) { + for (const [name, scheme] of Object.entries(schemes)) { + if (!isObject(scheme)) continue; + if (scheme.type === 'apiKey' && (scheme.in === 'query' || scheme.in === 'cookie')) { + results.push({ + message: `securityScheme "${name}" is apiKey in "${scheme.in}"; credentials must travel in the Authorization header, not query/cookie`, + path: [...base, 'components', 'securitySchemes', name], + }); + } + } + } + + const scanParams = (params, path) => { + if (!Array.isArray(params)) return; + params.forEach((p, idx) => { + if (!isObject(p)) return; + if ((p.in === 'query' || p.in === 'path') && typeof p.name === 'string' && SUSPECT_NAME.test(p.name)) { + results.push({ + message: `parameter "${p.name}" (in: ${p.in}) looks like a credential; credentials must travel in the Authorization header, not query/path`, + path: [...path, idx, 'name'], + }); + } + }); + }; + + const paths = targetVal.paths; + if (isObject(paths)) { + for (const [route, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + scanParams(pathItem.parameters, [...base, 'paths', route, 'parameters']); + for (const op of OPS) { + const operation = pathItem[op]; + if (isObject(operation)) scanParams(operation.parameters, [...base, 'paths', route, op, 'parameters']); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-headerEcho.js b/api-design-guide/linter/functions/s08-headerEcho.js new file mode 100644 index 0000000..1a5196c --- /dev/null +++ b/api-design-guide/linter/functions/s08-headerEcho.js @@ -0,0 +1,61 @@ +import { isObject, statusMatcher } from './lib/util.js'; + +/** + * s08 headerEcho — proxy: IF an operation declares a given request header + * parameter, THEN its matching (default 2xx) responses must declare a given + * response header. Drives guide 8.2 (Accept-Language -> Content-Language): + * a "localisation request" is proxied as one that carries Accept-Language. + * + * Does NOT verify: that the echoed value is actually derived from the + * request (only that the response declares the header), nor requests that + * omit Accept-Language entirely (that MUST is about requests that ARE + * localising, which this proxy can only detect via the header's presence). + * + * `given` should select an operation, e.g. + * `$.paths[*][get,put,post,delete,patch]`. + * + * options: + * requestHeader {string} (required) request header parameter name (in: + * header) whose presence triggers the check. + * responseHeader {string} (required) header name each matching response + * must declare. + * status {string} status matcher for responses to check (default + * "2xx"). + * + * @param {unknown} targetVal - an operation object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function headerEcho(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + if (typeof opts.requestHeader !== 'string' || typeof opts.responseHeader !== 'string') return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const params = Array.isArray(targetVal.parameters) ? targetVal.parameters : []; + const reqName = opts.requestHeader.toLowerCase(); + const hasRequestHeader = params.some( + (p) => isObject(p) && p.in === 'header' && typeof p.name === 'string' && p.name.toLowerCase() === reqName, + ); + if (!hasRequestHeader) return; + + const responses = targetVal.responses; + if (!isObject(responses)) return; + const matchStatus = statusMatcher(opts.status || '2xx'); + const respName = opts.responseHeader.toLowerCase(); + const results = []; + + for (const [status, response] of Object.entries(responses)) { + if (!matchStatus(status) || !isObject(response)) continue; + const declared = isObject(response.headers) ? Object.keys(response.headers).map((h) => h.toLowerCase()) : []; + if (!declared.includes(respName)) { + results.push({ + message: `operation accepts "${opts.requestHeader}" but ${status} response does not declare "${opts.responseHeader}"`, + path: [...base, 'responses', status], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-idempotencyKeyRequired.js b/api-design-guide/linter/functions/s08-idempotencyKeyRequired.js new file mode 100644 index 0000000..824ac06 --- /dev/null +++ b/api-design-guide/linter/functions/s08-idempotencyKeyRequired.js @@ -0,0 +1,38 @@ +import { isObject } from './lib/util.js'; + +/** + * s08 idempotencyKeyRequired — proxy for guide 8.3: POST endpoints that + * require idempotency under §14 MUST accept an Idempotency-Key header, + * unless §14.6 applies. + * + * Proxy signal: a POST operation that declares a 201 response is treated as + * a "create" endpoint that requires idempotency. Does NOT verify the actual + * §14 idempotency-requirement determination, and does NOT check the §14.6 + * opt-out. + * + * `given` should select POST operations, e.g. `$.paths[*].post`. + * + * @param {unknown} targetVal - a POST operation object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function idempotencyKeyRequired(targetVal, options, context) { + if (!isObject(targetVal)) return; + const responses = targetVal.responses; + if (!isObject(responses) || !('201' in responses)) return; + + const params = Array.isArray(targetVal.parameters) ? targetVal.parameters : []; + const hasHeader = params.some( + (p) => isObject(p) && p.in === 'header' && typeof p.name === 'string' && p.name.toLowerCase() === 'idempotency-key', + ); + if (hasHeader) return; + + const base = context && Array.isArray(context.path) ? context.path : []; + return [ + { + message: 'POST operation returning 201 must accept an Idempotency-Key header parameter (unless §14.6 applies)', + path: [...base, 'parameters'], + }, + ]; +} diff --git a/api-design-guide/linter/functions/s08-noXHeaders.js b/api-design-guide/linter/functions/s08-noXHeaders.js new file mode 100644 index 0000000..420140f --- /dev/null +++ b/api-design-guide/linter/functions/s08-noXHeaders.js @@ -0,0 +1,87 @@ +import { isObject } from './lib/util.js'; + +const OPS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; + +/** + * s08 noXHeaders — guide 8.5: new custom headers MUST NOT use the `X-` + * prefix (RFC 6648). There is no exception on conforming new surfaces. + * + * Scans every header-carrying location in the document: operation and + * path-item parameters with `in: header`, response `headers` maps (inline + * on operations and under `components.responses`), and + * `components.parameters` entries with `in: header`. + * + * `given` should be the document root (`$`). + * + * @param {unknown} targetVal - the document root. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function noXHeaders(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const flagName = (name, path) => { + if (typeof name !== 'string') return; + if (/^x-/i.test(name)) { + results.push({ + message: `header "${name}" uses the reserved "X-" prefix (RFC 6648); rename without the X- prefix`, + path, + }); + } + }; + + const scanParams = (params, path) => { + if (!Array.isArray(params)) return; + params.forEach((p, idx) => { + if (isObject(p) && p.in === 'header') flagName(p.name, [...path, idx, 'name']); + }); + }; + + const scanResponses = (responses, path) => { + if (!isObject(responses)) return; + for (const [status, response] of Object.entries(responses)) { + if (!isObject(response) || !isObject(response.headers)) continue; + for (const headerName of Object.keys(response.headers)) { + flagName(headerName, [...path, status, 'headers', headerName]); + } + } + }; + + const paths = targetVal.paths; + if (isObject(paths)) { + for (const [route, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + scanParams(pathItem.parameters, [...base, 'paths', route, 'parameters']); + for (const op of OPS) { + const operation = pathItem[op]; + if (!isObject(operation)) continue; + scanParams(operation.parameters, [...base, 'paths', route, op, 'parameters']); + scanResponses(operation.responses, [...base, 'paths', route, op, 'responses']); + } + } + } + + const components = targetVal.components; + if (isObject(components)) { + const compParams = components.parameters; + if (isObject(compParams)) { + for (const [key, p] of Object.entries(compParams)) { + if (isObject(p) && p.in === 'header') flagName(p.name, [...base, 'components', 'parameters', key, 'name']); + } + } + const compResponses = components.responses; + if (isObject(compResponses)) { + for (const [key, response] of Object.entries(compResponses)) { + if (!isObject(response) || !isObject(response.headers)) continue; + for (const headerName of Object.keys(response.headers)) { + flagName(headerName, [...base, 'components', 'responses', key, 'headers', headerName]); + } + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-personalDataInUrl.js b/api-design-guide/linter/functions/s08-personalDataInUrl.js new file mode 100644 index 0000000..c984c47 --- /dev/null +++ b/api-design-guide/linter/functions/s08-personalDataInUrl.js @@ -0,0 +1,69 @@ +import { isObject } from './lib/util.js'; + +const OPS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; + +// Heuristic dictionary of personal-data term fragments. Deliberately broad +// (STRICT-ONLY): flags plausible personal-data parameter names, not a +// definitive determination. +const PERSONAL_DATA_TERMS = + /national[-_]?id|\bssn\b|social[-_]?security|passport|phone|mobile|e[-_]?mail|full[-_]?name|first[-_]?name|last[-_]?name|surname|date[-_]?of[-_]?birth|\bdob\b|birth[-_]?date|home[-_]?address|street[-_]?address/i; + +/** + * s08 personalDataInUrl — STRICT-ONLY heuristic for guide 8.6: personal data + * MUST NOT appear in path segments, query parameters, or header values; + * opaque server-generated IDs MUST be used to refer to citizen records in + * URLs. + * + * Flags path/query/header parameters whose NAME matches a dictionary of + * personal-data term fragments (e.g. `email`, `phone`, `nationalId`, + * `dateOfBirth`). Deliberately noisy: a parameter literally named `email` + * trips it even when it is e.g. an admin lookup field, hence STRICT-ONLY. + * + * Does NOT verify: header or query VALUES (only names are visible + * statically), synonyms outside the dictionary, or whether an ID is + * actually opaque/server-generated vs. a natural key that happens not to + * match the dictionary. + * + * `given` should be the document root (`$`). + * + * @param {unknown} targetVal - the document root. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function personalDataInUrl(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const scanParams = (params, path) => { + if (!Array.isArray(params)) return; + params.forEach((p, idx) => { + if (!isObject(p)) return; + if ( + (p.in === 'path' || p.in === 'query' || p.in === 'header') && + typeof p.name === 'string' && + PERSONAL_DATA_TERMS.test(p.name) + ) { + results.push({ + message: `parameter "${p.name}" (in: ${p.in}) looks like personal data; use an opaque server-generated identifier instead`, + path: [...path, idx, 'name'], + }); + } + }); + }; + + const paths = targetVal.paths; + if (isObject(paths)) { + for (const [route, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + scanParams(pathItem.parameters, [...base, 'paths', route, 'parameters']); + for (const op of OPS) { + const operation = pathItem[op]; + if (isObject(operation)) scanParams(operation.parameters, [...base, 'paths', route, op, 'parameters']); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-rateLimitHeaders.js b/api-design-guide/linter/functions/s08-rateLimitHeaders.js new file mode 100644 index 0000000..ee4ddc3 --- /dev/null +++ b/api-design-guide/linter/functions/s08-rateLimitHeaders.js @@ -0,0 +1,74 @@ +import { isObject } from './lib/util.js'; + +const LEGACY_RATE_LIMIT_HEADERS = ['ratelimit-limit', 'ratelimit-remaining', 'ratelimit-reset']; +const shouldCheck = (status) => /^2\d\d$/.test(status) || status === '429'; + +/** + * s08 rateLimitHeaders — proxy for guide 8.7: endpoints rate-limited by the + * BB itself MUST declare the structured RateLimit response header, and 429 + * responses MUST additionally declare Retry-After. Legacy three-header names + * are forbidden in `forbidLegacy` mode. + * + * Proxy signal: an operation that declares a 429 response is treated as + * "rate-limited by the BB itself" (the delegated-to-gateway exception, + * which requires only a prose statement, is not mechanically checkable). + * RateLimit is then required on that operation's 2xx and 429 responses. + * + * Does NOT verify: that rate limiting is actually implemented, the + * delegated-to-gateway prose exception, or the header's runtime field + * values. + * + * `given` should select an operation's `responses`, e.g. + * `$.paths[*][get,put,post,delete,patch].responses`. + * + * @param {unknown} targetVal - a responses object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function rateLimitHeaders(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + const statuses = Object.keys(targetVal); + + if (options?.forbidLegacy === true) { + for (const [status, response] of Object.entries(targetVal)) { + const declared = isObject(response) && isObject(response.headers) + ? Object.keys(response.headers).map((header) => header.toLowerCase()) + : []; + const legacy = LEGACY_RATE_LIMIT_HEADERS.filter((header) => declared.includes(header)); + if (legacy.length) { + results.push({ + message: `${status} response declares legacy rate-limit headers: ${legacy.join(', ')}`, + path: [...base, status, 'headers'], + }); + } + } + return results.length ? results : undefined; + } + + if (!statuses.includes('429')) return; + + const response429 = targetVal['429']; + const declared429 = isObject(response429) && isObject(response429.headers) + ? Object.keys(response429.headers).map((h) => h.toLowerCase()) + : []; + if (!declared429.includes('retry-after')) { + results.push({ message: '429 response must declare a "Retry-After" header', path: [...base, '429'] }); + } + + for (const status of statuses.filter(shouldCheck)) { + const response = targetVal[status]; + if (!isObject(response)) continue; + const declared = isObject(response.headers) ? Object.keys(response.headers).map((h) => h.toLowerCase()) : []; + if (!declared.includes('ratelimit')) { + results.push({ + message: `${status} response must declare the structured "RateLimit" header`, + path: [...base, status], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s08-traceContext.js b/api-design-guide/linter/functions/s08-traceContext.js new file mode 100644 index 0000000..93aee30 --- /dev/null +++ b/api-design-guide/linter/functions/s08-traceContext.js @@ -0,0 +1,40 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** Advisory cross-service proxy: effective nonempty security requires traceparent. */ +export default function traceContext(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const rootSecurity = Array.isArray(targetVal.security) ? targetVal.security : null; + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + const results = []; + + for (const [pathKey, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + for (const method of METHODS) { + const operation = pathItem[method]; + if (!isObject(operation)) continue; + const effectiveSecurity = Array.isArray(operation.security) ? operation.security : rootSecurity; + if (!Array.isArray(effectiveSecurity) || effectiveSecurity.length === 0) continue; + const parameters = [ + ...(Array.isArray(pathItem.parameters) ? pathItem.parameters : []), + ...(Array.isArray(operation.parameters) ? operation.parameters : []), + ]; + const hasTraceparent = parameters.some( + (parameter) => + isObject(parameter) && + parameter.in === 'header' && + typeof parameter.name === 'string' && + parameter.name.toLowerCase() === 'traceparent', + ); + if (!hasTraceparent) { + results.push({ + message: `secured operation ${method.toUpperCase()} ${pathKey} should declare the W3C traceparent request header`, + path: [...base, 'paths', pathKey, method, 'parameters'], + }); + } + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-abbreviations.js b/api-design-guide/linter/functions/s09-abbreviations.js new file mode 100644 index 0000000..20cf2be --- /dev/null +++ b/api-design-guide/linter/functions/s09-abbreviations.js @@ -0,0 +1,76 @@ +import { forEachProperty } from './lib/schemaWalk.js'; +import { isObject } from './lib/util.js'; + +/** + * s09-abbreviations — STRICT proxy for §9.6 (avoid abbreviations). Splits each + * declared property name into words (camelCase / snake / kebab boundaries) and + * flags any word that appears in an abbreviation dictionary, suggesting the + * expansion. Deliberately noisy — ships only in the strict profile. + * + * `given` should select a schema (e.g. `$.components.schemas[*]`). Cycle-safe. + * + * options: + * abbreviations {object} map of abbreviation -> preferred word (lowercase + * keys). Replaces the default dictionary when given. + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const DEFAULT_ABBREVIATIONS = { + qty: 'quantity', + amt: 'amount', + num: 'number', + nbr: 'number', + addr: 'address', + msg: 'message', + desc: 'description', + cfg: 'configuration', + dob: 'dateOfBirth', + tmp: 'temporary', + txn: 'transaction', + acct: 'account', + cust: 'customer', + dept: 'department', + mgr: 'manager', + pwd: 'password', + usr: 'user', + cnt: 'count', + idx: 'index', + fname: 'firstName', + lname: 'lastName', + pct: 'percent', + attr: 'attribute', + err: 'error', +}; + +function splitWords(name) { + return String(name) + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[\s_-]+/) + .filter(Boolean); +} + +export default function s09Abbreviations(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = isObject(options) ? options : {}; + const dict = isObject(opts.abbreviations) ? opts.abbreviations : DEFAULT_ABBREVIATIONS; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + forEachProperty(targetVal, (name, _schema, path) => { + for (const word of splitWords(name)) { + const preferred = dict[word.toLowerCase()]; + if (preferred) { + results.push({ + message: `field "${name}" uses abbreviation "${word}"; prefer "${preferred}" (§9.6)`, + path: [...base, ...path], + }); + } + } + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-bbCode.js b/api-design-guide/linter/functions/s09-bbCode.js new file mode 100644 index 0000000..5094f5a --- /dev/null +++ b/api-design-guide/linter/functions/s09-bbCode.js @@ -0,0 +1,110 @@ +import { isObject } from './lib/util.js'; + +/** + * s09-bbCode — PROXY for §9.11 (single registered BB code), IN-DOCUMENT scope + * only. Extracts BB-code segments from the identifier namespaces the guide + * defines and checks two things within one document: + * + * 1. every extracted BB code matches `^[a-z][a-z0-9-]{1,30}$`; + * 2. all extracted BB codes are identical (the same BB uses one code across + * OAuth scopes, problem-type URIs, event types and logical channel IDs). + * + * Extraction runs over every object key and string value (skipping free-text + * prose keys) using the two documented shapes: + * - OAuth scope: `bb:{bb-code}:{resource}:{action}` + * - problem type: `https://govstack.global/problems/{bb-code}/{slug}` + * - reverse-DNS: `global.govstack.{bb-code}....` (event types and logical + * channel IDs) + * + * It does NOT verify ecosystem-wide uniqueness of the BB code — that needs the + * cross-repo BB-code register (Appendix A), which is out of scope here. + * + * `given` should be `$` (the whole document). Cycle-safe. + * + * options: + * skipKeys {string[]} object keys whose string values are prose and skipped + * (default description/summary/title/externalDocs). + * + * @param {unknown} targetVal - the document root. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const SCOPE_RE = /^bb:([^:\s]+):/; +const RDNS_RE = /global\.govstack\.([^.\s]+)\./gi; +const PROBLEM_TYPE_RE = /https:\/\/govstack\.global\/problems\/([^/\s?#]+)\//gi; +const BB_CODE_RE = /^[a-z][a-z0-9-]{1,30}$/; +const DEFAULT_SKIP_KEYS = ['description', 'summary', 'title', 'externalDocs', 'address']; + +function truncate(s) { + return s.length > 60 ? `${s.slice(0, 57)}...` : s; +} + +export default function s09BbCode(targetVal, options, context) { + if (!isObject(targetVal) && !Array.isArray(targetVal)) return; + const opts = isObject(options) ? options : {}; + const skipKeys = new Set( + Array.isArray(opts.skipKeys) ? opts.skipKeys.map(String) : DEFAULT_SKIP_KEYS, + ); + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + const seen = new WeakSet(); + const codes = new Map(); // valid, non-reserved code -> first path seen + + const consider = (str, path) => { + if (typeof str !== 'string' || str.length === 0) return; + const raw = []; + const scope = SCOPE_RE.exec(str); + if (scope) raw.push(scope[1]); + RDNS_RE.lastIndex = 0; + let m; + while ((m = RDNS_RE.exec(str)) !== null) raw.push(m[1]); + PROBLEM_TYPE_RE.lastIndex = 0; + while ((m = PROBLEM_TYPE_RE.exec(str)) !== null) raw.push(m[1]); + + for (const code of raw) { + if (!BB_CODE_RE.test(code)) { + results.push({ + message: `BB code "${code}" in "${truncate(str)}" must match ^[a-z][a-z0-9-]{1,30}$ (§9.11)`, + path, + }); + continue; + } + if (!codes.has(code)) codes.set(code, path); + } + }; + + const walk = (node, path) => { + if (typeof node === 'string') { + consider(node, path); + return; + } + if (Array.isArray(node)) { + if (seen.has(node)) return; + seen.add(node); + node.forEach((item, i) => walk(item, [...path, i])); + return; + } + if (!isObject(node) || seen.has(node)) return; + seen.add(node); + for (const key of Object.keys(node)) { + consider(key, [...path, key]); // scope strings / addresses can be map keys + if (skipKeys.has(key)) continue; // skip prose values + walk(node[key], [...path, key]); + } + }; + + walk(targetVal, base); + + if (codes.size > 1) { + const list = [...codes.keys()]; + results.push({ + message: + `document uses ${codes.size} distinct BB codes (${list.join(', ')}); a BB must use its single ` + + `registered code identically across problem types, scopes, event types and logical channel IDs (§9.11)`, + path: base, + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-booleanStrings.js b/api-design-guide/linter/functions/s09-booleanStrings.js new file mode 100644 index 0000000..b599f98 --- /dev/null +++ b/api-design-guide/linter/functions/s09-booleanStrings.js @@ -0,0 +1,67 @@ +import { forEachProperty } from './lib/schemaWalk.js'; +import { isObject, toRegExp } from './lib/util.js'; + +/** + * s09-booleanStrings — PROXY for §9.3 (real JSON booleans). Flags properties + * that look boolean but are modelled as strings. Two heuristics, each a finding: + * + * 1. a property whose NAME matches a boolean-ish pattern (is/has/can prefixes) + * whose schema `type` includes "string"; + * 2. a property whose schema `enum` is made up solely of the string literals + * "true"/"false" (any case), regardless of name. + * + * It does NOT catch boolean-ish fields typed as integer 0/1, nor boolean values + * that are typed string without a boolean-ish name — those are not mechanically + * distinguishable from legitimate strings. + * + * `given` should select a schema (e.g. `$.components.schemas[*]`). Cycle-safe. + * + * options: + * namePattern {string} regex a property NAME must match for heuristic 1. + * nameFlags {string} regex flags for namePattern. + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const DEFAULT_NAME_PATTERN = + '^(is|has|can|should|must|will|allow|allows|enable|enables|require|requires|use|uses|include|includes)[A-Z0-9]'; + +export default function s09BooleanStrings(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = isObject(options) ? options : {}; + const nameRe = toRegExp(opts.namePattern || DEFAULT_NAME_PATTERN, opts.nameFlags); + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + forEachProperty(targetVal, (name, schema, path) => { + if (!isObject(schema)) return; + const full = [...base, ...path]; + const type = schema.type; + const typeList = Array.isArray(type) ? type : type === undefined ? [] : [type]; + + // Heuristic 1: boolean-looking name modelled as a string. + if (nameRe && nameRe.test(name) && typeList.includes('string')) { + results.push({ + message: `boolean-looking field "${name}" is typed string; use a real JSON boolean (true/false)`, + path: full, + }); + } + + // Heuristic 2: enum of the string literals "true"/"false". + const en = schema.enum; + if ( + Array.isArray(en) && + en.length > 0 && + en.every((v) => typeof v === 'string' && ['true', 'false'].includes(v.toLowerCase())) + ) { + results.push({ + message: `field "${name}" enumerates string booleans (${JSON.stringify(en)}); use a real JSON boolean`, + path: full, + }); + } + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-closedEnum.js b/api-design-guide/linter/functions/s09-closedEnum.js new file mode 100644 index 0000000..1d93857 --- /dev/null +++ b/api-design-guide/linter/functions/s09-closedEnum.js @@ -0,0 +1,55 @@ +import { walkSchema } from './lib/schemaWalk.js'; +import { isObject } from './lib/util.js'; + +/** + * s09-closedEnum — PROXY for §9.9 (no closed enums for growing sets). Flags + * every closed string `enum` that offers no forward-compatibility escape hatch, + * i.e. that has neither an `x-extensible-enum` annotation nor a catch-all + * fallback member (UNKNOWN / OTHER / UNSPECIFIED). + * + * It CANNOT tell whether a value set is "expected to grow", so it also flags + * truly-fixed enums (ISO codes, etc.) that §9.9 explicitly permits — hence this + * is an info-level proxy the reviewer must judge, not a hard error. + * + * `given` should select a schema (e.g. `$.components.schemas[*]`). Cycle-safe. + * + * options: + * fallbackMembers {string[]} enum members that count as a fallback + * (default UNKNOWN/OTHER/UNSPECIFIED, case-insensitive). + * extensionKey {string} annotation that opts an enum out (default + * "x-extensible-enum"). + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const DEFAULT_FALLBACKS = ['UNKNOWN', 'OTHER', 'UNSPECIFIED']; + +export default function s09ClosedEnum(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = isObject(options) ? options : {}; + const fallbacks = new Set( + (Array.isArray(opts.fallbackMembers) ? opts.fallbackMembers : DEFAULT_FALLBACKS).map((s) => + String(s).toLowerCase(), + ), + ); + const extKey = typeof opts.extensionKey === 'string' ? opts.extensionKey : 'x-extensible-enum'; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + walkSchema(targetVal, (node, path) => { + if (!Array.isArray(node.enum) || node.enum.length === 0) return; + if (!node.enum.every((v) => typeof v === 'string')) return; // only string enums grow + if (node[extKey] !== undefined) return; // annotated open enum + if (node.enum.some((v) => fallbacks.has(v.toLowerCase()))) return; // has a fallback member + results.push({ + message: + `closed enum ${JSON.stringify(node.enum)} has no x-extensible-enum annotation and no ` + + `UNKNOWN/OTHER fallback member; if this value set may grow, open it (§9.9). Truly-fixed sets may ignore.`, + path: [...base, ...path, 'enum'], + }); + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-enumCasing.js b/api-design-guide/linter/functions/s09-enumCasing.js new file mode 100644 index 0000000..a6f5d26 --- /dev/null +++ b/api-design-guide/linter/functions/s09-enumCasing.js @@ -0,0 +1,93 @@ +import { walkSchema } from './lib/schemaWalk.js'; +import { isObject, toRegExp } from './lib/util.js'; + +/** + * s09-enumCasing — PROXY for §9.7 (SCREAMING_SNAKE_CASE enum values). Flags + * string `enum` members that are not SCREAMING_SNAKE_CASE, minus the carve-outs + * §9.7 lists for values whose form is fixed by another rule or an external + * standard: + * + * - BCP 47 language tags, reverse-DNS identifiers (error codes, event types), + * and media types, matched by `allowPattern`; + * - the GovStack x-govstack-* extension vocabularies and the JOSE/COSE + * algorithm and curve names that are not already SCREAMING_SNAKE_CASE, + * listed in `allowValues`. + * + * Because those exceptions are pattern-based, a lowercase enum that merely looks + * ISO-like (any 2-3 letter token) is not flagged: the proxy trades a few misses + * for far fewer false positives. Non-string enum members are ignored. + * + * §9.7 exempts "values registered in an IANA registry", which no document linter + * can decide: registry membership is not visible in the spec. The literal list + * below covers the JOSE/COSE names the guide names and the neighbours an API is + * likely to declare beside them; a registered value outside it is a miss, and + * the fix is to add it here rather than to re-case the value. + * + * Known false positive: §12.7 sort keys are field names in lowerCamelCase, which + * is indistinguishable from a mis-cased state name, so a sort-key enum declared + * under components.schemas is flagged. Inline sort parameters are not visited. + * + * `given` should select a schema (e.g. `$.components.schemas[*]`). Cycle-safe. + * + * options: + * pattern {string} regex an enum value MUST match (default SCREAMING_SNAKE). + * allowPattern {string} regex of always-allowed values (default ISO-ish codes). + * allowValues {string[]} literal always-allowed values (default: the + * x-govstack-* and JOSE/COSE vocabularies below). + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const SCREAMING = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$/; +// Three shapes §9.7 carves out and that are distinguishable from a mis-cased +// state name: BCP 47 language tags (§10.9), reverse-DNS identifiers built to a +// shape this guide defines (error codes §11.5, event types §16.3), and IANA +// media types. +const DEFAULT_ALLOW_PATTERN = + '^([a-z]{2,3}([-_][A-Za-z0-9]{2,8})*|[a-z][a-zA-Z0-9]*(\\.[a-zA-Z0-9-]+)+|[a-z]+/[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]*)$'; +const DEFAULT_ALLOW_VALUES = [ + // JOSE/COSE algorithm and curve names (§9.7) that are not already + // SCREAMING_SNAKE_CASE. The all-caps ones (ES256, RS256, HS256, A128GCM …) + // satisfy the default pattern and need no entry. + 'EdDSA', + 'Ed25519', + 'Ed448', + 'X25519', + 'X448', + 'P-256', + 'P-384', + 'P-521', + 'secp256k1', + 'ECDH-ES', +]; + +export default function s09EnumCasing(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = isObject(options) ? options : {}; + const pattern = (opts.pattern && toRegExp(opts.pattern)) || SCREAMING; + const allowRe = + opts.allowPattern !== undefined ? toRegExp(opts.allowPattern) : toRegExp(DEFAULT_ALLOW_PATTERN); + const allowValues = new Set( + (Array.isArray(opts.allowValues) ? opts.allowValues : DEFAULT_ALLOW_VALUES).map(String), + ); + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + walkSchema(targetVal, (node, path) => { + if (!Array.isArray(node.enum)) return; + node.enum.forEach((value, i) => { + if (typeof value !== 'string' || value.length === 0) return; + if (pattern.test(value)) return; + if (allowValues.has(value)) return; + if (allowRe && allowRe.test(value)) return; + results.push({ + message: `enum value "${value}" must be SCREAMING_SNAKE_CASE (e.g. ACTIVE, PENDING_REVIEW)`, + path: [...base, ...path, 'enum', i], + }); + }); + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s09-extensionPrefix.js b/api-design-guide/linter/functions/s09-extensionPrefix.js new file mode 100644 index 0000000..b072064 --- /dev/null +++ b/api-design-guide/linter/functions/s09-extensionPrefix.js @@ -0,0 +1,75 @@ +import { isObject } from './lib/util.js'; + +/** + * s09-extensionPrefix — STRICT proxy for §9.10 (GovStack extension prefix). + * Walks the whole document and flags specification extensions (`x-*` keys) that + * look GovStack-defined but are not prefixed exactly `x-govstack-`: + * + * 1. an `x-` whose token is a known GovStack extension concept + * (deprecated, api-guide) -> should be + * `x-govstack-`; + * 2. any `x-*` key that mentions "govstack" but is not prefixed + * `x-govstack-` (typos / wrong casing / wrong separator). + * + * It is weak: it cannot know the full set of GovStack-defined extensions, so it + * misses GovStack extensions with unknown names, and cannot tell a legitimate + * third-party `x-` extension from a GovStack one. Ships only in strict. + * + * `given` should be `$` (the whole document). Cycle-safe. + * + * options: + * knownExtensions {string[]} known GovStack extension tokens (suffix after + * `x-govstack-`), replacing the default list. + * + * @param {unknown} targetVal - the document root. + * @param {object} [options] + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +const DEFAULT_KNOWN = ['deprecated', 'api-guide']; + +export default function s09ExtensionPrefix(targetVal, options, context) { + if (!isObject(targetVal) && !Array.isArray(targetVal)) return; + const opts = isObject(options) ? options : {}; + const known = new Set( + (Array.isArray(opts.knownExtensions) ? opts.knownExtensions : DEFAULT_KNOWN).map(String), + ); + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + const seen = new WeakSet(); + + const walk = (node, path) => { + if (Array.isArray(node)) { + if (seen.has(node)) return; + seen.add(node); + node.forEach((item, i) => { + if (isObject(item) || Array.isArray(item)) walk(item, [...path, i]); + }); + return; + } + if (!isObject(node) || seen.has(node)) return; + seen.add(node); + for (const key of Object.keys(node)) { + if (key.startsWith('x-')) { + const isGovstack = /^x-govstack-/.test(key); + const token = key.slice(2); // strip leading "x-" + if (!isGovstack && known.has(token)) { + results.push({ + message: `extension "${key}" names a GovStack-defined concept but lacks the prefix; use "x-govstack-${token}"`, + path: [...base, ...path, key], + }); + } else if (!isGovstack && /govstack/i.test(key)) { + results.push({ + message: `extension "${key}" references GovStack but is not prefixed exactly "x-govstack-"`, + path: [...base, ...path, key], + }); + } + } + const child = node[key]; + if (isObject(child) || Array.isArray(child)) walk(child, [...path, key]); + } + }; + + walk(targetVal, []); + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s11-fieldErrors.js b/api-design-guide/linter/functions/s11-fieldErrors.js new file mode 100644 index 0000000..d1b2adc --- /dev/null +++ b/api-design-guide/linter/functions/s11-fieldErrors.js @@ -0,0 +1,79 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * s11-fieldErrors — validate the shape of a field-level `errors` extension + * only when a problem schema actually declares that extension. + * + * This deliberately does not infer that every 400 response is a validation + * problem. Whether a failure is attributable to request fields is semantic; + * the linter only verifies the declared shape once a schema opts in. + * + * One level of `allOf` composition is inspected, matching envelopeShape's + * resolved-schema behaviour. Spectral resolves external references before + * invoking the function. + */ +function effective(schema) { + const required = new Set(asArray(schema.required).filter((name) => typeof name === 'string')); + const properties = isObject(schema.properties) ? { ...schema.properties } : {}; + for (const branch of asArray(schema.allOf)) { + if (!isObject(branch)) continue; + for (const name of asArray(branch.required)) { + if (typeof name === 'string') required.add(name); + } + if (isObject(branch.properties)) Object.assign(properties, branch.properties); + } + return { required, properties }; +} + +export default function s11FieldErrors(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const problem = effective(targetVal); + const errors = problem.properties.errors; + if (!isObject(errors)) return; + + const results = []; + if (!problem.required.has('errors')) { + results.push({ + message: 'a declared field-level errors array must be required', + path: [...base, 'required'], + }); + } + if (errors.type !== 'array') { + results.push({ + message: 'field-level errors must be an array', + path: [...base, 'properties', 'errors', 'type'], + }); + return results; + } + if (!isObject(errors.items)) { + results.push({ + message: 'field-level errors must declare an item schema', + path: [...base, 'properties', 'errors', 'items'], + }); + return results; + } + + const item = effective(errors.items); + if (Object.prototype.hasOwnProperty.call(item.properties, 'code')) { + results.push({ + message: 'field-error items must not declare "code"', + path: [...base, 'properties', 'errors', 'items', 'properties', 'code'], + }); + } + for (const name of ['pointer', 'message']) { + if (!item.required.has(name)) { + results.push({ + message: `field-error items must require "${name}"`, + path: [...base, 'properties', 'errors', 'items', 'required'], + }); + } + if (!isObject(item.properties[name])) { + results.push({ + message: `field-error items must declare "${name}"`, + path: [...base, 'properties', 'errors', 'items', 'properties'], + }); + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s11-problemType.js b/api-design-guide/linter/functions/s11-problemType.js new file mode 100644 index 0000000..de841e7 --- /dev/null +++ b/api-design-guide/linter/functions/s11-problemType.js @@ -0,0 +1,58 @@ +import { isObject, asArray } from './lib/util.js'; + +const TYPE_RE = /^https:\/\/govstack\.global\/problems\/[a-z][a-z0-9-]{1,30}\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; + +/** + * Validate literal RFC 9457 type values supplied with a problem response. + * The shared schema carries the same pattern for runtime instance validation; + * this function catches author-provided examples during normal Spectral lint. + */ +export default function s11ProblemType(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const check = (value, path) => { + if (typeof value !== 'string' || !TYPE_RE.test(value)) { + results.push({ + message: + 'problem type must match https://govstack.global/problems/{bb-code}/{kebab-problem-slug}', + path, + }); + } + }; + + if (isObject(targetVal.example) && 'type' in targetVal.example) { + check(targetVal.example.type, [...base, 'example', 'type']); + } + if (isObject(targetVal.examples)) { + for (const [name, example] of Object.entries(targetVal.examples)) { + const value = isObject(example) && 'value' in example ? example.value : example; + if (isObject(value) && 'type' in value) { + check(value.type, [...base, 'examples', name, ...(isObject(example) && 'value' in example ? ['value'] : []), 'type']); + } + } + } + + const schemas = [targetVal.schema, ...asArray(targetVal.schema?.allOf)].filter(isObject); + for (const schema of schemas) { + if (isObject(schema.example) && 'type' in schema.example) { + check(schema.example.type, [...base, 'schema', 'example', 'type']); + } + for (const [index, example] of asArray(schema.examples).entries()) { + if (isObject(example) && 'type' in example) { + check(example.type, [...base, 'schema', 'examples', index, 'type']); + } + } + const typeSchema = schema.properties?.type; + if (isObject(typeSchema)) { + if ('const' in typeSchema) check(typeSchema.const, [...base, 'schema', 'properties', 'type', 'const']); + for (const [index, value] of asArray(typeSchema.enum).entries()) { + check(value, [...base, 'schema', 'properties', 'type', 'enum', index]); + } + if ('example' in typeSchema) check(typeSchema.example, [...base, 'schema', 'properties', 'type', 'example']); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s12-collectionPagination.js b/api-design-guide/linter/functions/s12-collectionPagination.js new file mode 100644 index 0000000..3c8acdb --- /dev/null +++ b/api-design-guide/linter/functions/s12-collectionPagination.js @@ -0,0 +1,230 @@ +import { isObject, asArray } from './lib/util.js'; +import { isStandardUnversionedPath } from './lib/standardEndpoints.js'; +import envelopeShape from './envelopeShape.js'; + +/** + * s12-collectionPagination — pagination checks for a "collection" GET + * operation that need to branch on whether the operation opted into + * offset-based pagination (guide §12.6) instead of the cursor-based default + * (guide §12.2/§12.3). Reuses `envelopeShape` for the actual shape check so + * the envelope rules (cursor vs. flat/offset) stay in one place. + * + * `given` should select the operation object itself, e.g. + * `$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]` (a "collection" path: + * one that does not end in a `{param}` segment). + * + * An operation is considered to have opted into offset pagination when its + * `parameters` array declares a parameter named `offset`. + * + * Two carve-outs apply to every mode, so that the whole of §12's collection + * surface answers them the same way: + * - the guide §5.10 standard unversioned endpoints are not collections; + * - a collection whose 200 response declares its own bound with `maxItems` + * may be returned unpaginated (guide §12.1). + * + * options: + * mode {'pageParam'|'cursorParams'|'cursorEnvelope'|'pageSizeBounds'|'offsetEnvelope'} (required) + * - pageParam: operation MUST declare some page-size-like query + * parameter (`pageSize` or `offset`). + * - cursorParams: operation MUST declare both `pageSize` and `cursor` + * query parameters. No-op when the operation has an + * `offset` parameter (that operation is covered by the + * `offsetEnvelope` mode / §12.6 instead). + * - cursorEnvelope: the 200 response body schema MUST declare the §12.3 + * envelope `{ items, pageInfo: { nextCursor } }`, with a + * nullable non-empty cursor and no declared `hasMore` + * member. + * No-op when the operation has an `offset` parameter. + * - pageSizeBounds: the `pageSize` parameter MUST exist and declare both a + * `default` and a `maximum` (guide §12.4). + * - offsetEnvelope: the 200 response body schema MUST declare the §12.6 + * flat envelope `{ items, offset, limit, total }`. + * No-op when the operation does NOT have an `offset` + * parameter (nothing to check: it isn't using offset + * pagination). + * mediaType {string} content-type key to inspect on the 200 response, + * default "application/json". + * + * @param {unknown} targetVal - an operation object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +/** + * Guide §12.1 lets a collection go unpaginated when the specification itself + * fixes its size and says so with `maxItems`. Every array the response returns + * must carry that bound: one unbounded array is enough to make the response + * unbounded. + * + * @param {unknown} schema - the 200 response body schema. + * @returns {boolean} true when the response declares its own bound. + */ +function hasDeclaredBound(schema) { + if (!isObject(schema)) return false; + const arrays = []; + if (schema.type === 'array') arrays.push(schema); + if (isObject(schema.properties)) { + for (const prop of Object.values(schema.properties)) { + if (isObject(prop) && prop.type === 'array') arrays.push(prop); + } + } + if (arrays.length === 0) return false; + return arrays.every((a) => Number.isInteger(a.maxItems)); +} + +/** Merge a schema's direct shape with one level of allOf composition. */ +function effective(schema) { + if (!isObject(schema)) return { required: new Set(), properties: {} }; + const required = new Set(asArray(schema.required).filter((name) => typeof name === 'string')); + const properties = isObject(schema.properties) ? { ...schema.properties } : {}; + for (const branch of asArray(schema.allOf)) { + if (!isObject(branch)) continue; + for (const name of asArray(branch.required)) { + if (typeof name === 'string') required.add(name); + } + if (isObject(branch.properties)) Object.assign(properties, branch.properties); + } + return { required, properties }; +} + +function nullableNonEmptyString(schema) { + if (!isObject(schema)) return false; + const directTypes = asArray(schema.type); + const directTypeSet = new Set(directTypes); + if ( + directTypes.length === 2 && + directTypeSet.size === 2 && + directTypeSet.has('string') && + directTypeSet.has('null') + ) { + return Number.isInteger(schema.minLength) && schema.minLength >= 1; + } + + const oneOf = asArray(schema.oneOf); + const anyOf = asArray(schema.anyOf); + if ((oneOf.length > 0) === (anyOf.length > 0)) return false; + const branches = oneOf.length > 0 ? oneOf : anyOf; + if (branches.length !== 2 || !branches.every(isObject)) return false; + const stringBranch = branches.find((branch) => branch.type === 'string'); + const nullBranch = branches.find((branch) => branch.type === 'null'); + return Boolean( + stringBranch && + nullBranch && + Number.isInteger(stringBranch.minLength) && + stringBranch.minLength >= 1, + ); +} + +export default function collectionPagination(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = isObject(options) ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const mediaType = typeof opts.mediaType === 'string' ? opts.mediaType : 'application/json'; + const responseSchema = targetVal.responses?.['200']?.content?.[mediaType]?.schema; + + // Guide §12 covers "endpoints returning collections". The guide §5.10 + // standard unversioned endpoints are not collections, so exempt them by path + // key. `given` selects the GET operation, so context.path is + // ['paths', '', 'get'] — the key sits before 'get'. + const pathKey = base.length >= 2 ? base[base.length - 2] : undefined; + if (isStandardUnversionedPath(pathKey)) return undefined; + + // Guide §12.1: a collection bounded by its own schema may skip pagination, + // and with it the pagination parameters and envelopes of §12.2–§12.4. + if (hasDeclaredBound(responseSchema)) return undefined; + + const params = asArray(targetVal.parameters).filter(isObject); + const offsetMode = params.some((p) => p.name === 'offset'); + + if (opts.mode === 'pageParam') { + const names = new Set(params.map((p) => p.name)); + if (names.has('pageSize') || names.has('offset')) return undefined; + return [{ + message: 'collection endpoint must paginate: declare a "pageSize" (or, for offset pagination, "offset") query parameter, or bound the response with maxItems (guide 12.1)', + path: [...base, 'parameters'], + }]; + } + + if (opts.mode === 'pageSizeBounds') { + const pageSize = params.find((p) => p.name === 'pageSize'); + if (!pageSize) { + return [{ + message: 'collection endpoint must declare a "pageSize" query parameter with a documented default and maximum (guide 12.4)', + path: [...base, 'parameters'], + }]; + } + const schema = isObject(pageSize.schema) ? pageSize.schema : {}; + const missing = ['default', 'maximum'].filter((k) => schema[k] === undefined); + if (missing.length === 0) return undefined; + return [{ + message: `"pageSize" parameter schema must declare ${missing.join(' and ')} (guide 12.4)`, + path: [...base, 'parameters', params.indexOf(pageSize)], + }]; + } + + if (opts.mode === 'cursorParams') { + if (offsetMode) return undefined; + const names = new Set(params.map((p) => p.name)); + const results = []; + if (!names.has('pageSize')) { + results.push({ + message: 'collection endpoint must declare a "pageSize" query parameter (guide 12.2)', + path: [...base, 'parameters'], + }); + } + if (!names.has('cursor')) { + results.push({ + message: 'collection endpoint must declare a "cursor" query parameter; default pagination is cursor-based (guide 12.2)', + path: [...base, 'parameters'], + }); + } + return results.length ? results : undefined; + } + + if (opts.mode !== 'cursorEnvelope' && opts.mode !== 'offsetEnvelope') return undefined; + + const schema = responseSchema; + if (!isObject(schema)) return undefined; + const schemaPath = [...base, 'responses', '200', 'content', mediaType, 'schema']; + + if (opts.mode === 'cursorEnvelope') { + if (offsetMode) return undefined; + const findings = envelopeShape( + schema, + { + requiredProperties: ['items', 'pageInfo'], + properties: { + items: { type: 'array' }, + pageInfo: { + requiredProperties: ['nextCursor'], + forbiddenProperties: ['hasMore'], + }, + }, + }, + { path: schemaPath }, + ) ?? []; + + const pageInfo = effective(schema).properties.pageInfo; + if (isObject(pageInfo)) { + const nextCursor = effective(pageInfo).properties.nextCursor; + if (!nullableNonEmptyString(nextCursor)) { + findings.push({ + message: 'pageInfo.nextCursor must be an explicitly nullable, non-empty string', + path: [...schemaPath, 'properties', 'pageInfo', 'properties', 'nextCursor'], + }); + } + } + return findings.length ? findings : undefined; + } + + // mode === 'offsetEnvelope' + if (!offsetMode) return undefined; + return envelopeShape( + schema, + { + requiredProperties: ['items', 'offset', 'limit', 'total'], + properties: { items: { type: 'array' } }, + }, + { path: schemaPath }, + ); +} diff --git a/api-design-guide/linter/functions/s12-sortParam.js b/api-design-guide/linter/functions/s12-sortParam.js new file mode 100644 index 0000000..ebd0a96 --- /dev/null +++ b/api-design-guide/linter/functions/s12-sortParam.js @@ -0,0 +1,44 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * s12-sortParam — for an operation that declares a `sort` query parameter, + * assert its schema is a string carrying a `pattern` (guide 12.7's + * `field` / `-field`, comma-separated grammar). A no-op when the operation + * declares no `sort` parameter at all: sorting support is optional per + * operation. The guide recommends this shape once sorting is offered. + * + * Does NOT verify that the declared `pattern` actually encodes the exact + * field/-field/comma grammar, only that a pattern is present; validating an + * arbitrary author-supplied regex's semantics is not mechanically decidable. + * + * `given` should select an operation object, e.g. `$.paths[*][get]`. + * + * @param {unknown} targetVal - an operation object. + * @param {object} options - unused, present for signature consistency. + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function sortParamShape(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const params = asArray(targetVal.parameters).filter(isObject); + const idx = params.findIndex((p) => p.name === 'sort'); + if (idx === -1) return undefined; + + const param = params[idx]; + const schema = isObject(param.schema) ? param.schema : {}; + const path = [...base, 'parameters', idx, 'schema']; + const results = []; + + if (schema.type !== 'string') { + results.push({ message: '"sort" parameter schema should declare type "string"', path }); + } + if (typeof schema.pattern !== 'string' || schema.pattern.length === 0) { + results.push({ + message: '"sort" parameter schema should declare a "pattern" encoding the field/-field, comma-separated grammar (guide 12.7)', + path, + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s13-apiKeyScope.js b/api-design-guide/linter/functions/s13-apiKeyScope.js new file mode 100644 index 0000000..031a82c --- /dev/null +++ b/api-design-guide/linter/functions/s13-apiKeyScope.js @@ -0,0 +1,73 @@ +import { isObject } from './lib/util.js'; + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +// Path segments that identify an operational (health-like) endpoint per §5.9. +const OPERATIONAL = new Set([ + 'health', 'healthz', 'live', 'livez', 'ready', 'readyz', + 'metrics', 'ping', 'status', 'startup', 'startupz', +]); + +/** + * apiKeyScope — proxy for §13.6. API keys may protect operational endpoints + * (`/health` and similar) but MUST NOT protect operations that read or write + * personal data. This flags every operation that applies an `apiKey` security + * scheme yet is not an operational endpoint. + * + * It is a proxy: it cannot decide "reads or writes personal data", so it uses a + * path heuristic (§5.9 operational names) and flags apiKey use on everything + * else. It also cannot see credentials applied outside declared security + * requirements. + * + * Given: the document root ($). + * + * @param {unknown} targetVal - the document root. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function apiKeyScope(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const components = isObject(targetVal.components) ? targetVal.components : {}; + const schemes = isObject(components.securitySchemes) ? components.securitySchemes : {}; + const apiKeyNames = new Set( + Object.entries(schemes) + .filter(([, v]) => isObject(v) && v.type === 'apiKey') + .map(([k]) => k), + ); + if (apiKeyNames.size === 0) return; + + const rootSecurity = Array.isArray(targetVal.security) ? targetVal.security : undefined; + + const usesApiKey = (requirements) => + Array.isArray(requirements) && + requirements.some((req) => isObject(req) && Object.keys(req).some((n) => apiKeyNames.has(n))); + + const isOperational = (pathKey) => + pathKey + .split('/') + .filter((seg) => seg && !seg.startsWith('{')) + .some((seg) => OPERATIONAL.has(seg.toLowerCase())); + + const results = []; + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + for (const [pathKey, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (!isObject(op)) continue; + const effective = op.security !== undefined ? op.security : rootSecurity; + if (usesApiKey(effective) && !isOperational(pathKey)) { + results.push({ + message: + `operation ${method.toUpperCase()} ${pathKey} applies an apiKey scheme but is not an ` + + `operational (health-like) endpoint; API keys must not protect data operations`, + path: [...base, 'paths', pathKey, method], + }); + } + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s13-schemeExists.js b/api-design-guide/linter/functions/s13-schemeExists.js new file mode 100644 index 0000000..d420c74 --- /dev/null +++ b/api-design-guide/linter/functions/s13-schemeExists.js @@ -0,0 +1,68 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * schemeExists — assert the document declares at least one security scheme + * matching a required profile. Drives the §13.2 and §13.3 proxies: + * - §13.2: an OAuth 2.0 / OpenID Connect scheme exists. + * - §13.3: a service-to-service scheme exists (mTLS, or OAuth + * client-credentials). + * + * It only checks that such a scheme is *declared* under + * `components.securitySchemes`; it does NOT verify that the scheme is applied to + * the right (citizen-facing vs inter-BB) operations, which the guide leaves to + * human review. + * + * Given: the document root ($). Reads `components.securitySchemes`. + * + * options: + * types {string[]} scheme `type` values accepted unconditionally + * (e.g. ["openIdConnect","oauth2"] or ["mutualTLS"]). + * oauthFlows {string[]} if present, a `type: oauth2` scheme also matches when + * it declares at least one of these flow names under + * `flows` (e.g. ["clientCredentials"]). + * httpBearerFormats {string[]} if present, a `type: http` scheme with + * `scheme: bearer` also matches when its `bearerFormat` + * is one of these (e.g. ["JWT"]). This is the §13.2 + * resource-server case: an API that validates tokens + * from an authorization server it does not own would + * misdescribe itself by declaring an oauth2 flow. + * label {string} human label used in the message. + * + * @param {unknown} targetVal - the document root. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function schemeExists(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const types = asArray(opts.types).filter((t) => typeof t === 'string'); + const oauthFlows = asArray(opts.oauthFlows).filter((f) => typeof f === 'string'); + const httpBearerFormats = asArray(opts.httpBearerFormats).filter((f) => typeof f === 'string'); + const label = typeof opts.label === 'string' ? opts.label : 'a matching'; + + const components = isObject(targetVal.components) ? targetVal.components : {}; + const schemes = isObject(components.securitySchemes) ? components.securitySchemes : {}; + + const matches = (scheme) => { + if (!isObject(scheme)) return false; + const t = scheme.type; + if (typeof t === 'string' && types.includes(t)) return true; + if (t === 'oauth2' && oauthFlows.length && isObject(scheme.flows)) { + return oauthFlows.some((f) => isObject(scheme.flows[f])); + } + if (t === 'http' && httpBearerFormats.length && scheme.scheme === 'bearer') { + return httpBearerFormats.includes(scheme.bearerFormat); + } + return false; + }; + + const found = Object.values(schemes).some(matches); + if (found) return; + + const target = isObject(components.securitySchemes) + ? [...base, 'components', 'securitySchemes'] + : [...base]; + return [{ message: `no ${label} security scheme is declared under components.securitySchemes`, path: target }]; +} diff --git a/api-design-guide/linter/functions/s13-scopeNames.js b/api-design-guide/linter/functions/s13-scopeNames.js new file mode 100644 index 0000000..cd01baa --- /dev/null +++ b/api-design-guide/linter/functions/s13-scopeNames.js @@ -0,0 +1,48 @@ +import { isObject, toRegExp } from './lib/util.js'; + +/** + * scopeNames — assert every OAuth scope string obeys the GovStack scope naming + * convention (§13.4). Scopes are the *keys* of an oauth2 scheme's + * `flows..scopes` object. + * + * This is a proxy: it validates the *shape* of each scope string against the + * default `bb:{bb-code}:{resource}:{action}` and its two documented + * alternatives (reverse-DNS `global.govstack.{bb-code}.{resource}.{action}` and + * `resource.action`). It does NOT verify that `{bb-code}` is the BB's actual + * registered code (§9.11), that scopes are unique ecosystem-wide, that a single + * convention is used consistently, or that every operation documents its scopes. + * + * Given: a `scopes` object (`$.components.securitySchemes[*].flows[*].scopes`). + * + * options: + * patterns {string[]} regexes; each scope key must match at least one. + * flags {string} regex flags applied to every pattern. + * label {string} convention name used in the message. + * + * @param {unknown} targetVal - a scopes object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function scopeNames(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const label = typeof opts.label === 'string' ? opts.label : 'the GovStack scope naming convention'; + + const regexes = (Array.isArray(opts.patterns) ? opts.patterns : []) + .map((p) => toRegExp(p, typeof opts.flags === 'string' ? opts.flags : undefined)) + .filter((r) => r instanceof RegExp); + if (regexes.length === 0) return; + + const results = []; + for (const key of Object.keys(targetVal)) { + if (!regexes.some((r) => r.test(key))) { + results.push({ + message: `scope "${key}" does not match ${label} (bb:{bb-code}:{resource}:{action})`, + path: [...base, key], + }); + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s14-idempotencyKey.js b/api-design-guide/linter/functions/s14-idempotencyKey.js new file mode 100644 index 0000000..1e36a47 --- /dev/null +++ b/api-design-guide/linter/functions/s14-idempotencyKey.js @@ -0,0 +1,57 @@ +import { isObject, asArray, statusMatcher } from './lib/util.js'; + +/** + * idempotencyKey — proxy for §14.1. A POST that creates a resource (declares a + * 201 response) MUST accept an `Idempotency-Key` request header. This checks the + * create-POST case only. + * + * It is a proxy: it cannot decide the other MUST triggers (moves value, submits + * an irreversible request, sends a message, starts a long-running job, etc.) nor + * the SHOULD/MAY tiers, so it keys off the mechanically visible signal of a POST + * declaring a 201 response. + * + * Given: a path item (`$.paths[*]`). Reads its `post` operation plus both + * path-level and operation-level `parameters`. + * + * options: + * header {string} required header name (default "Idempotency-Key"). + * status {string} status matcher marking a create (default "201"). + * method {string} operation key to inspect (default "post"). + * + * @param {unknown} targetVal - a path item object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function idempotencyKey(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const header = typeof opts.header === 'string' ? opts.header : 'Idempotency-Key'; + const method = typeof opts.method === 'string' ? opts.method : 'post'; + const matchStatus = statusMatcher(typeof opts.status === 'string' ? opts.status : '201'); + + const op = targetVal[method]; + if (!isObject(op) || !isObject(op.responses)) return; + + const created = Object.keys(op.responses).filter(matchStatus); + if (created.length === 0) return; + + const params = [...asArray(targetVal.parameters), ...asArray(op.parameters)]; + const hasHeader = params.some( + (p) => + isObject(p) && + p.in === 'header' && + typeof p.name === 'string' && + p.name.toLowerCase() === header.toLowerCase(), + ); + if (hasHeader) return; + + const pathKey = base.length ? base[base.length - 1] : ''; + return [{ + message: + `${method.toUpperCase()} ${pathKey} declares a ${created[0]} response (a create) but does not ` + + `accept an "${header}" request header`, + path: [...base, method], + }]; +} diff --git a/api-design-guide/linter/functions/s15-cancelPath.js b/api-design-guide/linter/functions/s15-cancelPath.js new file mode 100644 index 0000000..5f6fcfe --- /dev/null +++ b/api-design-guide/linter/functions/s15-cancelPath.js @@ -0,0 +1,48 @@ +import { isObject } from './lib/util.js'; + +const CANONICAL = /^\/v\d+\/operations\/\{[^}]+\}\/cancel$/; + +/** + * cancelPath — §15.5. Cancellation of an asynchronous Operation, when + * supported, MUST be `POST /v{N}/operations/{operationId}/cancel`. + * + * Scope: only Operation-cancellation paths are checked — a path whose last + * segment is `cancel` and that contains an `operations` segment. Domain-level + * cancel actions (e.g. `/v1/orders/{id}/cancel`) are not Operation + * cancellations and are out of scope. A matching path must be the canonical + * shape and must declare a `post` operation. + * + * Given: the paths object ($.paths). + * + * @param {unknown} targetVal - the paths object. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function cancelPath(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + for (const [key, item] of Object.entries(targetVal)) { + if (typeof key !== 'string') continue; + const segments = key.split('/').filter(Boolean); + const last = segments[segments.length - 1]; + if (last !== 'cancel' || !segments.includes('operations')) continue; + + if (!CANONICAL.test(key)) { + results.push({ + message: `cancellation path "${key}" should use POST /v{N}/operations/{operationId}/cancel`, + path: [...base, key], + }); + continue; + } + if (!isObject(item) || !isObject(item.post)) { + results.push({ + message: `cancellation at "${key}" should use a POST operation`, + path: [...base, key], + }); + } + } + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s15-operationsPolling.js b/api-design-guide/linter/functions/s15-operationsPolling.js new file mode 100644 index 0000000..56ecd10 --- /dev/null +++ b/api-design-guide/linter/functions/s15-operationsPolling.js @@ -0,0 +1,53 @@ +import { isObject } from './lib/util.js'; + +const POLL_PATH = /^\/v\d+\/operations\/\{[^}]+\}$/; + +/** + * operationsPolling — proxy for §15.4. When a spec uses the asynchronous + * Operations pattern, clients poll via `GET /v{N}/operations/{operationId}`. + * This flags a spec that uses Operations but does not declare that canonical + * poll endpoint. + * + * "Uses Operations" is detected structurally: the document declares a local + * `components.schemas.Operation` schema, or has any path under `/operations/`. + * The function deliberately does not inspect the schema's fields or status + * values; those semantics belong to the BB. + * + * It is a proxy: it cannot decide whether asynchronous behaviour is actually + * needed, and it accepts any version major and any path-parameter name. + * + * Given: the document root ($). + * + * @param {unknown} targetVal - the document root. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function operationsPolling(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + const schemas = + isObject(targetVal.components) && isObject(targetVal.components.schemas) + ? targetVal.components.schemas + : {}; + + const pathKeys = Object.keys(paths); + const usesOperations = + isObject(schemas.Operation) || + pathKeys.some((k) => k.includes('/operations/') || k.endsWith('/operations')); + if (!usesOperations) return; + + const hasPoll = Object.entries(paths).some( + ([k, v]) => POLL_PATH.test(k) && isObject(v) && isObject(v.get), + ); + if (hasPoll) return; + + return [{ + message: + 'the Operations pattern is used but no canonical poll endpoint ' + + 'GET /v{N}/operations/{operationId} is declared', + path: [...base, 'paths'], + }]; +} diff --git a/api-design-guide/linter/functions/s16-eventField.js b/api-design-guide/linter/functions/s16-eventField.js new file mode 100644 index 0000000..bbc0d64 --- /dev/null +++ b/api-design-guide/linter/functions/s16-eventField.js @@ -0,0 +1,113 @@ +import { isObject, toRegExp } from './lib/util.js'; + +/** + * s16-eventField — inspect a named property of a CloudEvents envelope schema + * and assert its *pinned literal value(s)* satisfy / violate a pattern. + * + * Drives the §16 event-type (16.3) and stable-source (16.4) checks. The event + * `type` and `source` are ordinarily pinned on the envelope schema via `const`, + * `enum`, `default`, `example` or `examples`; this function locates the named + * property (merging one level of top-level `allOf`, the same limitation as + * envelopeShape), gathers those pinned string values, and tests each one. + * + * A free-form property (no pinned value) cannot be checked here and is left + * clean — presence of the property itself is enforced by §16.2 (envelopeShape). + * + * `given` should select the envelope schema, e.g. + * $.webhooks[*][post].requestBody.content[*].schema + * + * Options: + * property {string} (required) property name to inspect ("type", "source"). + * match {string} regex string; every pinned value MUST match it. + * forbidPattern {string} regex string; no pinned value may match it. + * forbidSegments {string} regex string; no dot-separated segment may match it + * (used to reject a version segment in an event type). + * sources {string[]} value keywords to gather (default ["const","enum"]). + * flags {string} regex flags applied to match/forbid patterns. + * name {string} label used in messages (default = property). + * expected {string} human description of the expected/forbidden state. + * + * @param {unknown} targetVal - a JSON Schema (the envelope schema). + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function eventField(targetVal, options, context) { + if (!isObject(targetVal) || !isObject(options)) return; + const property = options.property; + if (typeof property !== 'string' || property.length === 0) return; + + const base = context && Array.isArray(context.path) ? context.path : []; + const name = typeof options.name === 'string' ? options.name : property; + const expected = typeof options.expected === 'string' ? options.expected : undefined; + + const prop = findProperty(targetVal, property); + if (!isObject(prop)) return; // property not declared — §16.2 covers presence + + const values = gatherValues(prop, options.sources); + if (values.length === 0) return; // no pinned value to check + + const matchRe = toRegExp(options.match, options.flags); + const forbidRe = toRegExp(options.forbidPattern, options.flags); + const forbidSegRe = toRegExp(options.forbidSegments, options.flags); + + const path = [...base, 'properties', property]; + const results = []; + for (const value of values) { + if (matchRe && !matchRe.test(value)) { + results.push({ + message: `${name} "${value}" is not valid; expected ${expected || `it to match ${options.match}`}.`, + path, + }); + } + if (forbidRe && forbidRe.test(value)) { + results.push({ + message: `${name} "${value}" is not stable; ${expected || 'it must not contain a forbidden token'}.`, + path, + }); + } + if (forbidSegRe) { + for (const seg of value.split('.')) { + if (forbidSegRe.test(seg)) { + results.push({ + message: `${name} "${value}" must not contain a version segment ("${seg}"); ${expected || 'the version belongs in the channel address, not the event type'}.`, + path, + }); + break; + } + } + } + } + return results.length ? results : undefined; +} + +/** Locate a property in a schema, merging one level of top-level allOf. */ +function findProperty(schema, name) { + if (isObject(schema.properties) && isObject(schema.properties[name])) { + return schema.properties[name]; + } + if (Array.isArray(schema.allOf)) { + for (const branch of schema.allOf) { + if (isObject(branch) && isObject(branch.properties) && isObject(branch.properties[name])) { + return branch.properties[name]; + } + } + } + return undefined; +} + +/** Collect pinned string values from a property schema's value keywords. */ +function gatherValues(prop, sources) { + const keys = Array.isArray(sources) && sources.length ? sources : ['const', 'enum']; + const out = []; + for (const key of keys) { + const v = prop[key]; + if (v === undefined) continue; + if (key === 'enum' || key === 'examples') { + if (Array.isArray(v)) for (const item of v) if (typeof item === 'string') out.push(item); + } else if (typeof v === 'string') { + out.push(v); + } + } + return out; +} diff --git a/api-design-guide/linter/functions/s16-subscriptionEndpoints.js b/api-design-guide/linter/functions/s16-subscriptionEndpoints.js new file mode 100644 index 0000000..2fb0ea0 --- /dev/null +++ b/api-design-guide/linter/functions/s16-subscriptionEndpoints.js @@ -0,0 +1,65 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; + +/** + * s16-subscriptionEndpoints — guide 16.11 (proxy): subscription management MUST + * expose interfaces to create, list, and delete a subscription. This is a + * proxy: it can only reason about paths that are + * *named* as subscription resources. When a document exposes any subscription + * path, the three baseline capabilities are required; a document with no subscription + * path is left clean (absence of a subscription surface cannot be judged here, + * nor can the AsyncAPI message-command control plane the guide also permits). + * + * Capabilities are inferred from path shape and HTTP method: + * create POST on a collection path ending in `/subscriptions` + * list GET on that collection path + * delete DELETE on an item path `/subscriptions/{id}` + * + * `given` should be `$.paths`. + * + * @param {unknown} targetVal - the OpenAPI `paths` object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function subscriptionEndpoints(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const collection = new Set(); + const item = new Set(); + let sawSubscription = false; + + for (const [route, pathItem] of Object.entries(targetVal)) { + if (!isObject(pathItem)) continue; + const segments = route.split('/').filter((s) => s.length > 0); + if (!segments.some((s) => /^subscriptions?$/i.test(s))) continue; + sawSubscription = true; + + const methods = METHODS.filter((m) => isObject(pathItem[m])); + const last = segments[segments.length - 1] || ''; + + if (/^subscriptions?$/i.test(last)) { + methods.forEach((m) => collection.add(m)); + } else if (/^\{.+\}$/.test(last)) { + methods.forEach((m) => item.add(m)); + } + } + + if (!sawSubscription) return; // no subscription surface to judge + + const missing = []; + if (!collection.has('post')) missing.push('create (POST /…/subscriptions)'); + if (!collection.has('get')) missing.push('list (GET /…/subscriptions)'); + if (!item.has('delete')) missing.push('delete (DELETE /…/subscriptions/{id})'); + + if (missing.length === 0) return; + + return [ + { + message: `subscription management is exposed but missing: ${missing.join(', ')}. §16.11 requires create, list, and delete interfaces.`, + path: base, + }, + ]; +} diff --git a/api-design-guide/linter/functions/s17-channelIds.js b/api-design-guide/linter/functions/s17-channelIds.js new file mode 100644 index 0000000..a184409 --- /dev/null +++ b/api-design-guide/linter/functions/s17-channelIds.js @@ -0,0 +1,34 @@ +import { isObject } from './lib/util.js'; + +const LOGICAL_CHANNEL_ID_RE = + /^global\.govstack\.[a-z][a-z0-9-]{1,30}\.v[0-9]+(?:\.(?:[a-z][a-zA-Z0-9-]*|\{[a-zA-Z0-9_]+\})){2,}$/; + +/** + * Validate the stable logical IDs used as keys of an AsyncAPI channels map. + * Channel Object `address` values intentionally are not inspected here because + * they use protocol-native destination syntax (§17.2). + * + * @param {unknown} targetVal - the AsyncAPI channels map. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function channelIds(targetVal, _options, context) { + if (!isObject(targetVal)) return; + + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + for (const logicalId of Object.keys(targetVal)) { + if (LOGICAL_CHANNEL_ID_RE.test(logicalId)) continue; + + results.push({ + message: + `logical channel ID "${logicalId}" must match ` + + 'global.govstack.{bb-code}.v{major}.{resource}.{event}', + path: [...base, logicalId], + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s17-channelParameters.js b/api-design-guide/linter/functions/s17-channelParameters.js new file mode 100644 index 0000000..0b57c42 --- /dev/null +++ b/api-design-guide/linter/functions/s17-channelParameters.js @@ -0,0 +1,57 @@ +import { isObject, isNonEmptyString } from './lib/util.js'; + +/** + * s17-channelParameters — §17.4 declared channel parameters. + * + * Given: a single AsyncAPI 3.0 channel object (`$.channels[*]`). + * + * For every `{param}` token that appears in the channel `address`, asserts: + * - the parameter is declared under the channel `parameters` object, and + * - the declared parameter documents its routing semantics via a non-empty + * `description`. + * + * The AsyncAPI 3 Parameter Object has no `schema` field (unlike AsyncAPI 2.x); + * a parameter's allowed values are expressed with `enum`. §17.4's enum and + * examples clauses are [R] and are not checked here: whether a parameter's + * value set is closed cannot be determined from the document. + * + * options: none. + * @param {unknown} targetVal - a channel object. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17ChannelParameters(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const address = targetVal.address; + if (!isNonEmptyString(address)) return; // no address to parse + const base = context && Array.isArray(context.path) ? context.path : []; + const params = isObject(targetVal.parameters) ? targetVal.parameters : undefined; + const results = []; + + const seen = new Set(); + const re = /\{([^}]+)\}/g; + let m; + while ((m = re.exec(address)) !== null) { + const name = m[1]; + if (seen.has(name)) continue; + seen.add(name); + + if (!params || !Object.prototype.hasOwnProperty.call(params, name)) { + results.push({ + message: `channel parameter "${name}" used in address "${address}" must be declared under the channel "parameters" object`, + path: [...base, 'parameters'], + }); + continue; + } + const def = params[name]; + if (!isObject(def) || !isNonEmptyString(def.description)) { + results.push({ + message: `channel parameter "${name}" must state its routing semantics in a non-empty "description"`, + path: [...base, 'parameters', name], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s17-cloudEventsPayload.js b/api-design-guide/linter/functions/s17-cloudEventsPayload.js new file mode 100644 index 0000000..dcc3501 --- /dev/null +++ b/api-design-guide/linter/functions/s17-cloudEventsPayload.js @@ -0,0 +1,136 @@ +import { isObject, asArray } from './lib/util.js'; + +const CE_ENVELOPE_REQUIRED = ['specversion', 'id', 'source', 'type']; + +/** + * s17-cloudEventsPayload — §17.6 structured CloudEvents JSON payloads. + * + * Given: a single AsyncAPI 3.0 message object (`$.components.messages[*]`). + * + * For domain-event messages, asserts contentType is + * `application/cloudevents+json` and the payload declares the structured + * CloudEvents shape: `specversion` (const "1.0"), `id`, `source`, and `type`. + * Event `data` remains optional and locally specialised. One level of top-level + * `allOf` is merged. A reference to the reviewed shared CloudEventEnvelope + * contributes its known fields; base AsyncAPI validation separately proves + * that the external reference resolves. + * + * Messages that do not declare the CloudEvents media type, reference a + * CloudEvents envelope, or expose CloudEvents fields are OUT of scope. This is + * deliberate: the guide permits non-CloudEvents commands and transport-native + * messages, and a linter cannot infer that they are domain events. + * + * Options: + * requireSharedReference {boolean} when true, only check that the message + * payload references the vendored CloudEventEnvelope or GovStackAsyncError. + * The corresponding rule runs with `resolved: false` so the authored + * external reference remains visible. + * @param {unknown} targetVal - a message object. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17CloudEventsPayload(targetVal, options, context) { + if (!isObject(targetVal)) return; + const payload = targetVal.payload; + if (!isObject(payload)) return; // no inline payload schema to inspect + const base = context && Array.isArray(context.path) ? context.path : []; + if (isObject(options) && options.requireSharedReference === true) { + if ( + referencesVendoredSchema(payload, 'CloudEventEnvelope') || + referencesVendoredSchema(payload, 'GovStackAsyncError') + ) { + return; + } + if (!isCloudEventMessage(targetVal, payload) && !isAsyncErrorSchema(payload)) return; + return [ + { + message: + 'message payload must reference the schema from the vendored common/govstack-asyncapi-common.yaml file', + path: [...base, 'payload'], + }, + ]; + } + if (!isCloudEventMessage(targetVal, payload)) return; + const { required, properties } = effective(payload); + + const results = []; + const at = [...base, 'payload']; + if (targetVal.contentType !== 'application/cloudevents+json') { + results.push({ + message: 'CloudEvents Message Object must set contentType to "application/cloudevents+json"', + path: [...base, 'contentType'], + }); + } + for (const name of CE_ENVELOPE_REQUIRED) { + if (!required.has(name)) { + results.push({ message: `CloudEvents payload must list "${name}" in required`, path: [...at, 'required'] }); + } + } + const sv = properties.specversion; + if (isObject(sv) && 'const' in sv && sv.const !== '1.0') { + results.push({ + message: 'CloudEvents payload "specversion" must be declared with const "1.0"', + path: [...at, 'properties', 'specversion', 'const'], + }); + } + return results.length ? results : undefined; +} + +function isCloudEventMessage(message, payload) { + if (message.contentType === 'application/cloudevents+json') return true; + if (referencesSchema(payload, 'CloudEventEnvelope')) return true; + const { required, properties } = effective(payload); + return required.has('specversion') || properties.specversion !== undefined; +} + +function isAsyncErrorSchema(payload) { + if (referencesSchema(payload, 'GovStackAsyncError')) return true; + const { required, properties } = effective(payload); + const declared = (name) => required.has(name) || properties[name] !== undefined; + return !declared('status') && declared('code') && (declared('traceId') || declared('traceid')); +} + +/** Merge a schema's own required/properties with one level of allOf branches. */ +function effective(schema) { + const required = new Set(asArray(schema.required).filter((s) => typeof s === 'string')); + const properties = isObject(schema.properties) ? { ...schema.properties } : {}; + for (const branch of asArray(schema.allOf)) { + if (!isObject(branch)) continue; + if (referencesSchema(branch, 'CloudEventEnvelope')) { + for (const name of CE_ENVELOPE_REQUIRED) required.add(name); + properties.specversion ??= { const: '1.0' }; + } + for (const r of asArray(branch.required)) if (typeof r === 'string') required.add(r); + if (isObject(branch.properties)) { + for (const [k, v] of Object.entries(branch.properties)) if (!(k in properties)) properties[k] = v; + } + } + return { required, properties }; +} + +function referencesSchema(schema, name) { + if (!isObject(schema)) return false; + const suffix = `#/components/schemas/${name}`; + if (typeof schema.$ref === 'string' && schema.$ref.endsWith(suffix)) return true; + return asArray(schema.allOf).some( + (branch) => isObject(branch) && typeof branch.$ref === 'string' && branch.$ref.endsWith(suffix), + ); +} + +function referencesVendoredSchema(schema, name) { + if (!isObject(schema)) return false; + const pattern = new RegExp( + `(?:^|/)(?:api/)?common/govstack-asyncapi-common\\.yaml#/components/schemas/${name}$`, + ); + const matchesLocalRef = (ref) => + typeof ref === 'string' && + !ref.startsWith('/') && + !ref.startsWith('//') && + !/^[a-z][a-z+.-]*:/i.test(ref) && + pattern.test(ref); + if (matchesLocalRef(schema.$ref)) return true; + return asArray(schema.allOf).some( + (branch) => isObject(branch) && matchesLocalRef(branch.$ref), + ); +} diff --git a/api-design-guide/linter/functions/s17-protocolBindings.js b/api-design-guide/linter/functions/s17-protocolBindings.js new file mode 100644 index 0000000..bd6241f --- /dev/null +++ b/api-design-guide/linter/functions/s17-protocolBindings.js @@ -0,0 +1,100 @@ +import { isObject } from './lib/util.js'; + +/** + * s17-protocolBindings — §17.19 protocol bindings where relevant (PROXY). + * + * Given: the whole RESOLVED AsyncAPI 3.0 document (`$`). + * + * Proxy check. Collects the transport protocols declared on `servers` and, for + * each channel, asserts that a matching protocol binding is declared either on + * the channel itself or on any operation bound to it. Protocol names are + * normalised (wss->ws, amqps->amqp, mqtts->mqtt, kafka-secure->kafka, https-> + * http, …) before comparison. + * + * Does NOT verify the specific fields inside the bindings (Kafka topic/key, MQTT + * QoS/retained, AMQP exchange/queue/routing-key, WebSocket/SSE framing), nor + * whether protocol-specific fields genuinely affect interoperability. + * + * options: none. + * @param {unknown} targetVal - the document root ($). + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17ProtocolBindings(targetVal, _options, context) { + const root = targetVal; + if (!isObject(root) || !isObject(root.channels)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const serverProtocols = new Set(); + if (isObject(root.servers)) { + for (const srv of Object.values(root.servers)) { + if (isObject(srv) && typeof srv.protocol === 'string') { + const p = normaliseProtocol(srv.protocol); + if (p && BINDING_RELEVANT.has(p)) serverProtocols.add(p); + } + } + } + if (serverProtocols.size === 0) return; // no binding-relevant protocol to check against + + // address -> set of normalised binding keys contributed by channel + its ops + const byAddress = new Map(); + const bindingKeysOf = (node) => { + if (!isObject(node) || !isObject(node.bindings)) return []; + return Object.keys(node.bindings).map(normaliseProtocol).filter(Boolean); + }; + + for (const ch of Object.values(root.channels)) { + if (isObject(ch) && typeof ch.address === 'string') { + const set = byAddress.get(ch.address) || new Set(); + for (const k of bindingKeysOf(ch)) set.add(k); + byAddress.set(ch.address, set); + } + } + if (isObject(root.operations)) { + for (const op of Object.values(root.operations)) { + const ch = isObject(op) ? op.channel : undefined; + if (isObject(ch) && typeof ch.address === 'string') { + const set = byAddress.get(ch.address) || new Set(); + for (const k of bindingKeysOf(op)) set.add(k); + byAddress.set(ch.address, set); + } + } + } + + const results = []; + for (const [key, ch] of Object.entries(root.channels)) { + if (!isObject(ch)) continue; + const declared = new Set(bindingKeysOf(ch)); + if (typeof ch.address === 'string') for (const k of byAddress.get(ch.address) || []) declared.add(k); + const matches = [...serverProtocols].some((p) => declared.has(p)); + if (!matches) { + results.push({ + message: `channel "${key}" (or an operation bound to it) should declare protocol bindings for the server protocol(s) ${JSON.stringify([...serverProtocols])}`, + path: [...base, 'channels', key, 'bindings'], + }); + } + } + + return results.length ? results : undefined; +} + +const BINDING_RELEVANT = new Set([ + 'kafka', 'mqtt', 'amqp', 'amqp1', 'ws', 'http', 'sqs', 'sns', 'googlepubsub', + 'nats', 'stomp', 'redis', 'jms', 'pulsar', 'solace', 'ibmmq', 'anypointmq', +]); + +function normaliseProtocol(p) { + if (typeof p !== 'string') return undefined; + const s = p.toLowerCase(); + const map = { + wss: 'ws', + websocket: 'ws', + amqps: 'amqp', + mqtts: 'mqtt', + 'secure-mqtt': 'mqtt', + 'kafka-secure': 'kafka', + https: 'http', + }; + return map[s] || s; +} diff --git a/api-design-guide/linter/functions/s17-rejectionMessage.js b/api-design-guide/linter/functions/s17-rejectionMessage.js new file mode 100644 index 0000000..f494a60 --- /dev/null +++ b/api-design-guide/linter/functions/s17-rejectionMessage.js @@ -0,0 +1,119 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * s17-rejectionMessage — §17.16 async rejection/failure messages (PROXY). + * + * Given: the whole RESOLVED AsyncAPI 3.0 document (`$`). + * + * Proxy check. The guide requires that command-like messages which can be + * rejected asynchronously define a rejection/failure message using the common + * §11 error envelope, correlated to the original message. This function CANNOT + * identify which messages are "command-like", so, when the document declares any + * operations, it asserts: + * - at least one `components.messages` entry references GovStackAsyncError or + * declares its transport-neutral `code`+`traceId`/`traceid` shape without + * an HTTP `status`, and + * - at least one such error message declares a correlation mechanism (a + * message-level `correlationId`, or a header/payload property whose name + * contains "correlation"). + * + * Does NOT verify: which specific operations need a rejection message, nor that + * the correlation actually points at the initiating message. + * + * options: none. + * @param {unknown} targetVal - the document root ($). + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17RejectionMessage(targetVal, _options, context) { + const root = targetVal; + if (!isObject(root) || !isObject(root.operations) || Object.keys(root.operations).length === 0) return; + const base = context && Array.isArray(context.path) ? context.path : []; + + const messages = isObject(root.components) ? root.components.messages : undefined; + const entries = isObject(messages) ? Object.entries(messages) : []; + + const errorMessages = entries.filter(([, msg]) => isErrorEnvelope(msg)); + + if (errorMessages.length === 0) { + return [ + { + message: + 'no async rejection/failure message using the §11 error envelope was found under components.messages; command-like messages that can be rejected asynchronously must define one', + path: [...base, 'components', 'messages'], + }, + ]; + } + + const correlated = errorMessages.some(([, msg]) => hasCorrelation(msg)); + if (!correlated) { + const [name] = errorMessages[0]; + return [ + { + message: `async rejection message "${name}" must declare correlation metadata (a correlationId or a correlation header/attribute) linking it to the original message`, + path: [...base, 'components', 'messages', name], + }, + ]; + } + + return undefined; +} + +/** Property names declared on a schema, merging one level of allOf. */ +function propNames(schema) { + const names = new Set(); + if (!isObject(schema)) return names; + if (isObject(schema.properties)) for (const n of Object.keys(schema.properties)) names.add(n); + for (const branch of asArray(schema.allOf)) { + if (isObject(branch) && isObject(branch.properties)) for (const n of Object.keys(branch.properties)) names.add(n); + } + return names; +} + +/** True when a set of property names looks like the §11 problem envelope. */ +function problemShaped(names) { + const has = (n) => names.has(n); + return !has('status') && has('code') && (has('traceId') || has('traceid')); +} + +function isErrorEnvelope(msg) { + if (!isObject(msg)) return false; + const payload = msg.payload; + if (referencesSchema(payload, 'GovStackAsyncError')) return true; + // Bare §11 envelope, or CloudEvents-wrapped with the problem under `data`. + if (problemShaped(propNames(payload))) return true; + for (const data of propertySchemas(payload, 'data')) { + if (referencesSchema(data, 'GovStackAsyncError')) return true; + if (problemShaped(propNames(data))) return true; + } + return false; +} + +/** Property schemas declared directly or in one level of allOf. */ +function propertySchemas(schema, name) { + if (!isObject(schema)) return []; + const values = []; + if (isObject(schema.properties?.[name])) values.push(schema.properties[name]); + for (const branch of asArray(schema.allOf)) { + if (isObject(branch) && isObject(branch.properties?.[name])) { + values.push(branch.properties[name]); + } + } + return values; +} + +function referencesSchema(schema, name) { + return ( + isObject(schema) && + typeof schema.$ref === 'string' && + schema.$ref.endsWith(`#/components/schemas/${name}`) + ); +} + +function hasCorrelation(msg) { + if (!isObject(msg)) return false; + if (isObject(msg.correlationId)) return true; + const names = [...propNames(msg.payload), ...propNames(msg.headers)]; + return names.some((n) => /correlation/i.test(n)); +} diff --git a/api-design-guide/linter/functions/s17-requestReply.js b/api-design-guide/linter/functions/s17-requestReply.js new file mode 100644 index 0000000..3bac52f --- /dev/null +++ b/api-design-guide/linter/functions/s17-requestReply.js @@ -0,0 +1,65 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * s17-requestReply — §17.17 declared request-reply correlation. + * + * Given: a single RESOLVED AsyncAPI 3.0 operation object (`$.operations[*]`). + * Resolution turns `reply.channel` / message `$ref`s into objects, which this + * walk relies on to find declared correlation. + * + * The rule only bites when the operation declares a `reply` (request-reply). For + * such an operation it asserts: + * - a reply target is declared: `reply.channel` or `reply.address`, and + * - a correlation mechanism is declared: some reachable message (the reply's + * messages, the reply channel's messages, or the operation's own messages) + * declares a `correlationId`, or `reply.address` carries a `location`. + * + * The inverse guidance ("fire-and-forget MUST NOT pretend to be request-reply") + * is not mechanically decidable and is not enforced here. + * + * options: none. + * @param {unknown} targetVal - an operation object. + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17RequestReply(targetVal, _options, context) { + if (!isObject(targetVal)) return; + const reply = targetVal.reply; + if (reply === undefined) return; // not request-reply — nothing to check + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + if (!isObject(reply)) { + return [{ message: 'operation "reply" must be an object declaring a reply channel/address and correlation', path: [...base, 'reply'] }]; + } + + const hasTarget = isObject(reply.channel) || isObject(reply.address); + if (!hasTarget) { + results.push({ + message: 'request-reply operation must declare the reply channel or reply address pattern under "reply"', + path: [...base, 'reply'], + }); + } + + const candidateMessages = []; + const collect = (v) => { + for (const m of asArray(v)) if (isObject(m)) candidateMessages.push(m); + }; + collect(reply.messages); + if (isObject(reply.channel) && isObject(reply.channel.messages)) collect(Object.values(reply.channel.messages)); + collect(targetVal.messages); + if (isObject(targetVal.channel) && isObject(targetVal.channel.messages)) collect(Object.values(targetVal.channel.messages)); + + const addressCorrelated = isObject(reply.address) && reply.address.location !== undefined; + const messageCorrelated = candidateMessages.some((m) => isObject(m.correlationId)); + + if (!addressCorrelated && !messageCorrelated) { + results.push({ + message: 'request-reply operation must declare a correlation mechanism (a message "correlationId" or a "reply.address.location")', + path: [...base, 'reply'], + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s17-securityCoverage.js b/api-design-guide/linter/functions/s17-securityCoverage.js new file mode 100644 index 0000000..185d39d --- /dev/null +++ b/api-design-guide/linter/functions/s17-securityCoverage.js @@ -0,0 +1,64 @@ +import { isObject } from './lib/util.js'; + +/** + * s17-securityCoverage — §17.10 AsyncAPI security covers every operation. + * + * Given: the whole RESOLVED AsyncAPI 3.0 document (`$`). Resolution turns each + * operation's `channel` `$ref` into the channel object and each server/operation + * `security` `$ref` into the scheme object, which this walk relies on. + * + * Asserts: + * - `components.securitySchemes` declares at least one scheme, and + * - every operation is covered: it declares a non-empty operation-level + * `security`, OR every server applicable to its channel declares a non-empty + * `security`. In AsyncAPI 3.0 `security` is a list of security scheme + * objects/references; a non-empty list counts as "secured". + * + * Applicable servers = the channel's `servers` list when present, otherwise all + * document servers. Message signing (§16.5) is deliberately NOT treated as a + * substitute here: only `servers`/`operations` security counts. + * + * options: none. + * @param {unknown} targetVal - the document root ($). + * @param {object} _options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function s17SecurityCoverage(targetVal, _options, context) { + const root = targetVal; + if (!isObject(root) || !isObject(root.operations)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + const schemes = isObject(root.components) ? root.components.securitySchemes : undefined; + if (!isObject(schemes) || Object.keys(schemes).length === 0) { + results.push({ + message: 'AsyncAPI security schemes must be declared under components.securitySchemes', + path: [...base, 'components', 'securitySchemes'], + }); + } + + const allServers = isObject(root.servers) ? Object.values(root.servers) : []; + const secured = (srv) => isObject(srv) && Array.isArray(srv.security) && srv.security.length > 0; + + for (const [opId, op] of Object.entries(root.operations)) { + if (!isObject(op)) continue; + if (Array.isArray(op.security) && op.security.length > 0) continue; // operation-level security + + const channel = op.channel; + const channelServers = + isObject(channel) && Array.isArray(channel.servers) && channel.servers.length > 0 + ? channel.servers + : allServers; + + const covered = channelServers.length > 0 && channelServers.every(secured); + if (!covered) { + results.push({ + message: `operation "${opId}" is not covered by a security scheme: it declares no operation-level security and its applicable server(s) declare none`, + path: [...base, 'operations', opId], + }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s18-deprecatedHeaders.js b/api-design-guide/linter/functions/s18-deprecatedHeaders.js new file mode 100644 index 0000000..cc691b2 --- /dev/null +++ b/api-design-guide/linter/functions/s18-deprecatedHeaders.js @@ -0,0 +1,59 @@ +import { isObject } from './lib/util.js'; + +const METHODS = ['get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace']; +const SUCCESS_STATUS = /^2\d{2}$/; + +/** + * s18-deprecatedHeaders — guide 18.5: operations marked `deprecated: true` + * MUST return a Deprecation header (RFC 9745) and a Sunset header (RFC 8594) + * on their 2xx responses. + * + * `given` should be `$.paths`. Walks operations directly (rather than + * filtering via a `given` JSONPath filter chained after a bracketed method + * list) because that chained-filter form does not select reliably in this + * Spectral/nimma version - see the comment on govstack-18.5 in s18.yaml. + * + * Only checks header PRESENCE; the RFC 9745/8594 structured-field VALUE + * syntax is not checkable from a static header declaration. + * + * @param {unknown} targetVal - the paths object. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function deprecatedHeaders(targetVal, options, context) { + if (!isObject(targetVal)) return; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + for (const [pathKey, pathItem] of Object.entries(targetVal)) { + if (!isObject(pathItem)) continue; + for (const method of METHODS) { + const operation = pathItem[method]; + if (!isObject(operation) || operation.deprecated !== true) continue; + const responses = operation.responses; + if (!isObject(responses)) continue; + for (const [status, response] of Object.entries(responses)) { + if (!SUCCESS_STATUS.test(status) || !isObject(response)) continue; + const headers = isObject(response.headers) + ? Object.keys(response.headers).map((h) => h.toLowerCase()) + : []; + const here = [...base, pathKey, method, 'responses', status]; + if (!headers.includes('deprecation')) { + results.push({ + message: `${status} response of deprecated operation "${method.toUpperCase()} ${pathKey}" must declare a "Deprecation" header`, + path: here, + }); + } + if (!headers.includes('sunset')) { + results.push({ + message: `${status} response of deprecated operation "${method.toUpperCase()} ${pathKey}" must declare a "Sunset" header`, + path: here, + }); + } + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s18-versionMajorConsistency.js b/api-design-guide/linter/functions/s18-versionMajorConsistency.js new file mode 100644 index 0000000..925f496 --- /dev/null +++ b/api-design-guide/linter/functions/s18-versionMajorConsistency.js @@ -0,0 +1,83 @@ +import { isObject } from './lib/util.js'; + +const OPENAPI_VERSION_SEG = /^\/v(\d+)(?:\/|$)/; +const ASYNCAPI_VERSION_SEG = /(?:^|\.)v(\d+)(?:\.|$)/; + +/** + * s18-versionMajorConsistency — guide 18.2: a major version increment MUST be + * reflected in the OpenAPI URL path (`/v2/`) or AsyncAPI logical channel ID, + * and that reflected major MUST match `info.version`'s major segment. + * + * `given` should be `$` (the whole document), so the function can read + * `info.version` alongside `paths`/`channels` in one pass. + * + * options: + * surface {string} required, "openapi" | "asyncapi". + * "openapi" - every `paths` key that already carries a `/v{N}/` prefix + * must have N == info.version's major. Paths with no version + * prefix at all are guide 5.1's concern, not this rule's. + * "asyncapi" - every logical ID (key under `channels`) must embed a `.v{N}.` + * segment matching info.version's major. Does NOT verify the + * text's alternative "equivalent machine-readable version + * field documented in govstack-asyncapi-common.yaml" (that + * needs the vendored common file). + * + * Non-object input, or an info.version that isn't a plain SemVer-shaped + * string, returns undefined (guide 18.1 owns validating info.version itself). + * + * @param {unknown} targetVal - the document root. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function versionMajorConsistency(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + + const version = isObject(targetVal.info) ? targetVal.info.version : undefined; + if (typeof version !== 'string') return; + const versionMajorMatch = version.match(/^(\d+)\./); + if (!versionMajorMatch) return; + const infoMajor = versionMajorMatch[1]; + + const results = []; + + if (opts.surface === 'openapi') { + const paths = targetVal.paths; + if (!isObject(paths)) return; + for (const key of Object.keys(paths)) { + const m = typeof key === 'string' ? key.match(OPENAPI_VERSION_SEG) : null; + if (!m) continue; + if (m[1] !== infoMajor) { + results.push({ + message: `path "${key}" declares version v${m[1]} but info.version is "${version}" (major ${infoMajor}); the path's major version must match info.version's major`, + path: [...base, 'paths', key], + }); + } + } + } else if (opts.surface === 'asyncapi') { + const channels = targetVal.channels; + if (!isObject(channels)) return; + for (const key of Object.keys(channels)) { + const m = key.match(ASYNCAPI_VERSION_SEG); + if (!m) { + results.push({ + message: `logical channel ID "${key}" does not include a major version segment (e.g. ".v${infoMajor}.")`, + path: [...base, 'channels', key], + }); + continue; + } + if (m[1] !== infoMajor) { + results.push({ + message: `logical channel ID "${key}" declares version v${m[1]} but info.version is "${version}" (major ${infoMajor}); the channel ID's major version must match info.version's major`, + path: [...base, 'channels', key], + }); + } + } + } else { + return; + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/s19-localisationHeaders.js b/api-design-guide/linter/functions/s19-localisationHeaders.js new file mode 100644 index 0000000..117b0e1 --- /dev/null +++ b/api-design-guide/linter/functions/s19-localisationHeaders.js @@ -0,0 +1,91 @@ +import { isObject, asArray } from './lib/util.js'; + +/** + * s19-localisationHeaders — guide 19.4 proxy: "Responses or messages with + * localised content MUST include the response language header appropriate to + * the surface". Whether a response/message actually carries localised + * content is not decidable from a static document, so this checks the + * checkable inverse: wherever the localisation *input* signal is present + * (an `Accept-Language` header parameter, or an `acceptLanguage` message + * header), the paired *output* language header MUST also be present + * somewhere it can be. + * + * options: + * surface {string} required, "openapi" | "asyncapi". + * "openapi" - `given` is one operation object + * (`$.paths[*][get,put,post,delete,patch,...]`). If the + * operation declares an `Accept-Language` header parameter, + * at least one response must declare a `Content-Language` + * header. + * "asyncapi" - `given` is one channel object (`$.channels[*]`). Pairing + * is done at the channel level, not per-message: if any + * message on the channel declares an `acceptLanguage` + * header, some message on the same channel must declare + * `contentLanguage`. A per-message check would be wrong, + * since guide 17.9 puts these on *different* messages + * (inbound vs outbound) by design. + * + * Does NOT verify: that a response/message lacking the input signal is + * nonetheless free of localised content — only the converse. + * + * @param {unknown} targetVal - an operation (openapi) or a channel (asyncapi). + * @param {object} options + * @returns {{message:string}[]|undefined} + */ +export default function localisationHeaders(targetVal, options) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + + if (opts.surface === 'openapi') { + const params = asArray(targetVal.parameters); + const hasAcceptLanguage = params.some( + (p) => + isObject(p) && + p.in === 'header' && + typeof p.name === 'string' && + p.name.toLowerCase() === 'accept-language' + ); + if (!hasAcceptLanguage) return; + + const responses = targetVal.responses; + if (!isObject(responses)) return; + const hasContentLanguage = Object.values(responses).some( + (resp) => isObject(resp) && isObject(resp.headers) && headerNames(resp.headers).includes('content-language') + ); + if (hasContentLanguage) return; + return [ + { + message: + 'operation declares an Accept-Language parameter but no response declares a Content-Language header', + }, + ]; + } + + if (opts.surface === 'asyncapi') { + const messages = targetVal.messages; + if (!isObject(messages)) return; + const messageList = Object.values(messages).filter(isObject); + const hasAcceptLanguage = messageList.some((m) => messageHeaderNames(m).includes('acceptlanguage')); + if (!hasAcceptLanguage) return; + const hasContentLanguage = messageList.some((m) => messageHeaderNames(m).includes('contentlanguage')); + if (hasContentLanguage) return; + return [ + { + message: + 'channel has a message declaring acceptLanguage but no message on the channel declares contentLanguage', + }, + ]; + } + + return undefined; +} + +function headerNames(headersObj) { + return Object.keys(headersObj).map((h) => h.toLowerCase()); +} + +function messageHeaderNames(message) { + const schema = isObject(message.headers) ? message.headers : undefined; + const props = schema && isObject(schema.properties) ? schema.properties : {}; + return Object.keys(props).map((h) => h.toLowerCase()); +} diff --git a/api-design-guide/linter/functions/schemaDescriptions.js b/api-design-guide/linter/functions/schemaDescriptions.js new file mode 100644 index 0000000..629677c --- /dev/null +++ b/api-design-guide/linter/functions/schemaDescriptions.js @@ -0,0 +1,79 @@ +import { walkSchema, forEachProperty } from './lib/schemaWalk.js'; +import { isObject, isNonEmptyString } from './lib/util.js'; + +// Nodes whose ONLY keywords are structural combinators carry no domain meaning +// of their own. A `required`-only assertion below `not` is likewise just a +// prohibition such as `not: { required: [legacyField] }`, not a schema editors +// need to describe. +const COMBINATOR_ONLY = new Set([ + 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', '$ref', 'description', 'title', +]); +const ASSERTION_ONLY = new Set(['required', 'description', 'title']); + +function isCombinatorWrapper(node) { + const keys = Object.keys(node); + return keys.length > 0 && keys.every((k) => COMBINATOR_ONLY.has(k)); +} + +function isNegatedRequiredAssertion(node, path) { + const keys = Object.keys(node); + return path.includes('not') && keys.length > 0 && keys.every((key) => ASSERTION_ONLY.has(key)); +} + +/** + * schemaDescriptions — assert schema nodes carry a non-empty `description`. + * + * `given` should select a schema. The walk is cycle-safe. + * + * options: + * mode {"properties"|"all"} default "properties". + * "properties" — every declared property schema (recursively) needs a + * non-empty description. Good default: documents each field. + * "all" — every subschema node needs one, except pure combinator + * wrappers (a node whose only keywords are allOf/anyOf/…) + * and required-only assertions nested under `not`. + * includeRoot {boolean} in "properties" mode, also require the root schema to + * have a description. Default false. + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function schemaDescriptions(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const mode = opts.mode === 'all' ? 'all' : 'properties'; + const results = []; + + if (mode === 'all') { + walkSchema(targetVal, (node, path) => { + if (isCombinatorWrapper(node) || isNegatedRequiredAssertion(node, path)) return; + if (!isNonEmptyString(node.description)) { + results.push({ + message: + path.length === 0 + ? 'schema must have a non-empty description' + : `schema at ${path.join('/')} must have a non-empty description`, + path: [...base, ...path], + }); + } + }); + } else { + if (opts.includeRoot === true && !isNonEmptyString(targetVal.description)) { + results.push({ message: 'schema must have a non-empty description', path: [...base] }); + } + forEachProperty(targetVal, (name, schema, path) => { + const desc = isObject(schema) ? schema.description : undefined; + if (!isNonEmptyString(desc)) { + results.push({ + message: `property "${name}" must have a non-empty description`, + path: [...base, ...path], + }); + } + }); + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/schemaFieldFormat.js b/api-design-guide/linter/functions/schemaFieldFormat.js new file mode 100644 index 0000000..323a6d4 --- /dev/null +++ b/api-design-guide/linter/functions/schemaFieldFormat.js @@ -0,0 +1,75 @@ +import { forEachProperty } from './lib/schemaWalk.js'; +import { isObject, toRegExp } from './lib/util.js'; + +/** + * schemaFieldFormat — for every property whose NAME matches a pattern, require + * its schema to declare given type/format/pattern/contentEncoding constraints. + * Drives the §10 data-type proxies (timestamps -> date-time, money -> object, + * base64 -> contentEncoding + maxLength, …). Cycle-safe walk. + * + * `given` should select a schema (e.g. a request/response body or + * `$.components.schemas[*]`). + * + * options: + * namePattern {string} regex a property NAME must match to be checked (req'd). + * nameFlags {string} regex flags for namePattern (e.g. "i"). + * require {object} constraints the matching property schema must declare: + * type {string} schema.type must equal (array type ok). + * format {string} schema.format must equal. + * formatOneOf {string[]} schema.format must be one of. + * contentEncoding {string} schema.contentEncoding must equal. + * pattern {string} schema.pattern must equal exactly. + * mustDeclare {string[]} these keywords must simply be present + * (e.g. ["maxLength"]). + * forbidType {string|string[]} schema.type must NOT be any of these + * (e.g. money field must not be "number"). + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function schemaFieldFormat(targetVal, options, context) { + if (!isObject(targetVal) || !isObject(options)) return; + const nameRe = toRegExp(options.namePattern, options.nameFlags); + if (!nameRe) return; + const req = isObject(options.require) ? options.require : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const results = []; + + forEachProperty(targetVal, (name, schema, path) => { + if (!nameRe.test(name)) return; + if (!isObject(schema)) return; + const full = [...base, ...path]; + const type = schema.type; + const typeList = Array.isArray(type) ? type : [type]; + + if (req.type !== undefined && !typeList.includes(req.type)) { + results.push({ message: `property "${name}" must declare type "${req.type}"`, path: full }); + } + if (req.forbidType !== undefined) { + const forbidden = Array.isArray(req.forbidType) ? req.forbidType : [req.forbidType]; + const hit = forbidden.find((t) => typeList.includes(t)); + if (hit) results.push({ message: `property "${name}" must not declare type "${hit}"`, path: full }); + } + if (req.format !== undefined && schema.format !== req.format) { + results.push({ message: `property "${name}" must declare format "${req.format}"`, path: full }); + } + if (Array.isArray(req.formatOneOf) && !req.formatOneOf.includes(schema.format)) { + results.push({ message: `property "${name}" format must be one of ${req.formatOneOf.join(', ')}`, path: full }); + } + if (req.contentEncoding !== undefined && schema.contentEncoding !== req.contentEncoding) { + results.push({ message: `property "${name}" must declare contentEncoding "${req.contentEncoding}"`, path: full }); + } + if (req.pattern !== undefined && schema.pattern !== req.pattern) { + results.push({ message: `property "${name}" must declare pattern ${JSON.stringify(req.pattern)}`, path: full }); + } + for (const kw of Array.isArray(req.mustDeclare) ? req.mustDeclare : []) { + if (!(kw in schema)) { + results.push({ message: `property "${name}" must declare "${kw}"`, path: full }); + } + } + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/schemaPropertyNames.js b/api-design-guide/linter/functions/schemaPropertyNames.js new file mode 100644 index 0000000..f0389a1 --- /dev/null +++ b/api-design-guide/linter/functions/schemaPropertyNames.js @@ -0,0 +1,46 @@ +import { forEachProperty } from './lib/schemaWalk.js'; +import { matchesCasing } from './lib/casing.js'; +import { toRegExp, isObject } from './lib/util.js'; + +/** + * schemaPropertyNames — assert every declared property name (recursively, + * across all subschemas) obeys a casing convention and/or a pattern. + * + * `given` should select a schema (e.g. a response/request body schema, or + * `$.components.schemas[*]`). The walk is cycle-safe. + * + * options (apply in combination; a name violating any active check is flagged): + * casing {string} one of camel|pascal|kebab|snake|screamingSnake|flat. + * allowPattern {string} regex a name MUST match. + * forbidPattern {string} regex a name MUST NOT match (e.g. spaces / non-ASCII). + * flags {string} regex flags for allow/forbid. + * + * @param {unknown} targetVal - a JSON Schema. + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function schemaPropertyNames(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + const allow = opts.allowPattern !== undefined ? toRegExp(opts.allowPattern, opts.flags) : undefined; + const forbid = opts.forbidPattern !== undefined ? toRegExp(opts.forbidPattern, opts.flags) : undefined; + const casing = typeof opts.casing === 'string' ? opts.casing : undefined; + const results = []; + + forEachProperty(targetVal, (name, _schema, path) => { + const problems = []; + if (casing && !matchesCasing(name, casing)) problems.push(`must be ${casing} case`); + if (allow && !allow.test(name)) problems.push(`must match ${allow.toString()}`); + if (forbid && forbid.test(name)) problems.push(`must not match ${forbid.toString()}`); + if (problems.length) { + results.push({ + message: `property name "${name}" ${problems.join('; ')}`, + path: [...base, ...path], + }); + } + }); + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/securityCoverage.js b/api-design-guide/linter/functions/securityCoverage.js new file mode 100644 index 0000000..ab0547b --- /dev/null +++ b/api-design-guide/linter/functions/securityCoverage.js @@ -0,0 +1,68 @@ +import { isObject } from './lib/util.js'; + +const HTTP_METHODS = ['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']; + +/** + * securityCoverage — check that operations are authenticated (or explicitly + * opted out). + * + * Two modes: + * + * mode "covered" (default) — `given` is the document root (`$`). Every operation + * must be covered by a security requirement: either a non-empty root-level + * `security`, or an operation-level `security`. An operation-level + * `security: []` counts as an explicit, allowed opt-out. Optionally requires + * `components.securitySchemes` to be present. + * options: { requireSchemes?: boolean } + * + * mode "none" — `given` is a single operation (e.g. the /health GET). The + * operation MUST declare `security: []` (explicitly unauthenticated). + * options: { mode: "none" } + * + * @param {unknown} targetVal + * @param {object} options + * @param {{path?: (string|number)[]}} [context] + * @returns {{message:string, path:(string|number)[]}[]|undefined} + */ +export default function securityCoverage(targetVal, options, context) { + if (!isObject(targetVal)) return; + const opts = options && typeof options === 'object' ? options : {}; + const base = context && Array.isArray(context.path) ? context.path : []; + + if (opts.mode === 'none') { + const sec = targetVal.security; + if (!Array.isArray(sec) || sec.length !== 0) { + return [{ message: 'operation must be unauthenticated (declare security: [])', path: [...base] }]; + } + return; + } + + // mode "covered": targetVal is the document root. + const results = []; + const rootSecurity = Array.isArray(targetVal.security) && targetVal.security.length > 0; + const components = isObject(targetVal.components) ? targetVal.components : {}; + const schemes = isObject(components.securitySchemes) ? Object.keys(components.securitySchemes) : []; + + if (opts.requireSchemes === true && schemes.length === 0) { + results.push({ message: 'components.securitySchemes must declare at least one scheme', path: [...base, 'components', 'securitySchemes'] }); + } + + const paths = isObject(targetVal.paths) ? targetVal.paths : {}; + for (const [pathKey, pathItem] of Object.entries(paths)) { + if (!isObject(pathItem)) continue; + for (const method of HTTP_METHODS) { + const op = pathItem[method]; + if (!isObject(op)) continue; + const opSecurity = op.security; + const covered = Array.isArray(opSecurity) || rootSecurity; + if (!covered) { + results.push({ + message: `operation ${method.toUpperCase()} ${pathKey} is not covered by any security requirement`, + path: [...base, 'paths', pathKey, method], + }); + } + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/functions/valuePattern.js b/api-design-guide/linter/functions/valuePattern.js new file mode 100644 index 0000000..dfa7db6 --- /dev/null +++ b/api-design-guide/linter/functions/valuePattern.js @@ -0,0 +1,47 @@ +import { toRegExp } from './lib/util.js'; + +/** + * valuePattern — assert a single string value matches / does not match a regex. + * + * `given` must select the string itself (e.g. `$.info.version`, + * `$.servers[*].url`, a channel address key). Spectral invokes the function + * once per selected value, so `targetVal` is one string. + * + * options: + * match {string} value MUST match this regex (anchored as written). + * notMatch {string} value MUST NOT match this regex. + * forbidPattern{string} alias of notMatch (reads better for deny-lists). + * flags {string} regex flags applied to both (e.g. "i"). + * name {string} human label for the value in messages (default "value"). + * + * Non-string input returns undefined (no error), so it composes safely with + * broad `given` selectors. + * + * @param {unknown} targetVal + * @param {object} options + * @returns {{message:string}[]|undefined} + */ +export default function valuePattern(targetVal, options) { + if (typeof targetVal !== 'string') return; + const opts = options && typeof options === 'object' ? options : {}; + const label = typeof opts.name === 'string' && opts.name ? opts.name : 'value'; + const flags = typeof opts.flags === 'string' ? opts.flags : undefined; + const results = []; + + if (opts.match !== undefined) { + const re = toRegExp(opts.match, flags); + if (re && !re.test(targetVal)) { + results.push({ message: `${label} "${targetVal}" must match ${re.toString()}` }); + } + } + + const deny = opts.notMatch !== undefined ? opts.notMatch : opts.forbidPattern; + if (deny !== undefined) { + const re = toRegExp(deny, flags); + if (re && re.test(targetVal)) { + results.push({ message: `${label} "${targetVal}" must not match ${re.toString()}` }); + } + } + + return results.length ? results : undefined; +} diff --git a/api-design-guide/linter/package-lock.json b/api-design-guide/linter/package-lock.json new file mode 100644 index 0000000..cbcb291 --- /dev/null +++ b/api-design-guide/linter/package-lock.json @@ -0,0 +1,3311 @@ +{ + "name": "govstack-api-lint", + "version": "0.1.0-draft", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "govstack-api-lint", + "version": "0.1.0-draft", + "dependencies": { + "@stoplight/spectral-cli": "^6.16.1", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-functions": "^1.10.1", + "@stoplight/spectral-parsers": "^1.0.5", + "@stoplight/spectral-ruleset-bundler": "^1.6.2", + "yaml": "^2.8.0" + }, + "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@asyncapi/specs": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.11.1.tgz", + "integrity": "sha512-A3WBLqAKGoJ2+6FWFtpjBlCQ1oFCcs4GxF7zsIGvNqp/klGUHjlA3aAcZ9XMMpLGE8zPeYDz2x9FmO6DSuKraQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.11" + } + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/ternary": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/ternary/-/ternary-1.1.4.tgz", + "integrity": "sha512-ck5wiqIbqdMX6WRQztBL7ASDty9YLgJ3sSAK5ZpBzXeySvFGCzIvM6UiAI4hTZ22fEcYQVV/zhUbNscggW+Ukg==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "22.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-22.0.2.tgz", + "integrity": "sha512-//NdP6iIwPbMTcazYsiBMbJW7gfmpHom33u1beiIoHDEM0Q9clvtQB1T0efvMqHeKsGohiHo97BCPCkBXdscwg==", + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "commondir": "^1.0.1", + "estree-walker": "^2.0.1", + "glob": "^7.1.6", + "is-reference": "^1.2.1", + "magic-string": "^0.25.7", + "resolve": "^1.17.0" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "rollup": "^2.68.0" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "license": "MIT", + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "license": "MIT" + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@stoplight/better-ajv-errors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz", + "integrity": "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA==", + "license": "Apache-2.0", + "dependencies": { + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": "^12.20 || >= 14.13" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@stoplight/json": { + "version": "3.21.7", + "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.7.tgz", + "integrity": "sha512-xcJXgKFqv/uCEgtGlPxy3tPA+4I+ZI4vAuMJ885+ThkTHFVkC+0Fm58lA9NlsyjnkpxFh4YiQWpH+KefHdbA0A==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.3", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "jsonc-parser": "~2.2.1", + "lodash": "^4.17.21", + "safe-stable-stringify": "^1.1" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-readers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-readers/-/json-ref-readers-1.2.2.tgz", + "integrity": "sha512-nty0tHUq2f1IKuFYsLM4CXLZGHdMn+X/IwEUIpeSOXt0QjMUbL0Em57iJUDzz+2MkWG83smIigNZ3fauGjqgdQ==", + "license": "Apache-2.0", + "dependencies": { + "node-fetch": "^2.6.0", + "tslib": "^1.14.1" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-readers/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/@stoplight/json-ref-resolver": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-resolver/-/json-ref-resolver-3.1.6.tgz", + "integrity": "sha512-YNcWv3R3n3U6iQYBsFOiWSuRGE5su1tJSiX6pAPRVk7dP0L7lqCteXGzuVRQ0gMZqUl8v1P0+fAKxF6PLo9B5A==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.21.0", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^12.3.0 || ^13.0.0", + "@types/urijs": "^1.19.19", + "dependency-graph": "~0.11.0", + "fast-memoize": "^2.5.2", + "immer": "^9.0.6", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "urijs": "^1.19.11" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/ordered-object-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz", + "integrity": "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/path": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@stoplight/path/-/path-1.3.2.tgz", + "integrity": "sha512-lyIc6JUlUA8Ve5ELywPC8I2Sdnh1zc1zmbYgVarhXIp9YeAB0ReeqmGEOWNtlHkbP2DAA1AL65Wfn2ncjK/jtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/spectral-cli": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-cli/-/spectral-cli-6.16.1.tgz", + "integrity": "sha512-6DdYx94d+BNVTdgJRkb1KJIcqYQsOxstAZtH7Kh63SDUsXFRkdfsBKsL7l6csxLRYYh5Qm6CSBF7AUFYBFJ23w==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/json": "~3.21.0", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-core": "^1.19.5", + "@stoplight/spectral-formatters": "^1.4.1", + "@stoplight/spectral-parsers": "^1.0.4", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-ruleset-bundler": "^1.6.0", + "@stoplight/spectral-ruleset-migrator": "^1.11.0", + "@stoplight/spectral-rulesets": ">=1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "chalk": "4.1.2", + "fast-glob": "~3.2.12", + "hpagent": "~1.2.0", + "lodash": "^4.18.1", + "pony-cause": "^1.1.1", + "stacktracey": "^2.1.8", + "tslib": "^2.8.1", + "yargs": "~17.7.2" + }, + "bin": { + "spectral": "dist/index.js" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-core": { + "version": "1.23.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-core/-/spectral-core-1.23.1.tgz", + "integrity": "sha512-VLC8OhpO/pMJKb6IHhurxJjXO1qB56Ng1unIb8b+hNxdw0+SEcASvmR+RpjfHYX/jv/DfSaA1x8QhFBJBmqBOQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "~3.21.0", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-parsers": "^1.0.0", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "~13.6.0", + "@types/es-aggregate-error": "^1.0.2", + "@types/json-schema": "^7.0.11", + "ajv": "^8.18.0", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "es-aggregate-error": "^1.0.7", + "expr-eval-fork": "^3.0.1", + "jsonpath-plus": "^10.3.0", + "lodash": "^4.18.1", + "lodash.topath": "^4.5.2", + "minimatch": "^3.1.4", + "nimma": "0.2.3", + "pony-cause": "^1.1.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/@stoplight/types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.6.0.tgz", + "integrity": "sha512-dzyuzvUjv3m1wmhPfq82lCVYGcXG0xUYgqnWfCq3PCVR4BKFhjdkHrnJ+jIDoMKvXb05AZP/ObQF6+NpDo29IQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@stoplight/spectral-formats": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formats/-/spectral-formats-1.8.5.tgz", + "integrity": "sha512-xaC0rCH0p7/bzNJsz+JgLSj+Cp6uwYGWpePQxdLkF2G6a8Zyp3OyS7umkGYNiimEwKrOjvCNNTFJpeuiENZSBA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.23.0", + "@types/json-schema": "^7.0.7", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-formatters": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formatters/-/spectral-formatters-1.5.1.tgz", + "integrity": "sha512-mGXaiIrPglPokSnbFqbkWN3DoozIbwrZAA6OgqSIl+djeD5+e6PMELg0g6r3ot3ZzntO+6/GXaDnxEQ/p9M/EQ==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/path": "^1.3.2", + "@stoplight/spectral-core": "^1.19.4", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.15.0", + "@types/markdown-escape": "^1.1.3", + "chalk": "4.1.2", + "cliui": "7.0.4", + "lodash": "^4.18.1", + "markdown-escape": "^2.0.0", + "node-sarif-builder": "^2.0.3", + "strip-ansi": "6.0", + "text-table": "^0.2.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-functions": { + "version": "1.10.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-functions/-/spectral-functions-1.10.5.tgz", + "integrity": "sha512-vDCd0NJ93715bcUpZZ5vNHiyxd4cgHF6tuXsDiXOXKAByg+I1fR5/dMijEo6Ce1Lz95a+RZ22JKYhF1YuzVvuA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.1", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-runtime": "^1.1.2", + "ajv": "^8.18.0", + "ajv-draft-04": "~1.0.0", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "lodash": "^4.18.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-functions/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@stoplight/spectral-parsers": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-parsers/-/spectral-parsers-1.0.5.tgz", + "integrity": "sha512-ANDTp2IHWGvsQDAY85/jQi9ZrF4mRrA5bciNHX+PUxPr4DwS6iv4h+FVWJMVwcEYdpyoIdyL+SRmHdJfQEPmwQ==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml": "~4.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-parsers/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-ref-resolver": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ref-resolver/-/spectral-ref-resolver-1.0.5.tgz", + "integrity": "sha512-gj3TieX5a9zMW29z3mBlAtDOCgN3GEc1VgZnCVlr5irmR4Qi5LuECuFItAq4pTn5Zu+sW5bqutsCH7D4PkpyAA==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json-ref-readers": "1.2.2", + "@stoplight/json-ref-resolver": "~3.1.6", + "@stoplight/spectral-runtime": "^1.1.2", + "dependency-graph": "0.11.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-bundler": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-bundler/-/spectral-ruleset-bundler-1.7.0.tgz", + "integrity": "sha512-PpIdj5Wje0T7ktxY8EUzBWLU0+mGGQHznT8nlQxTMnRhWLNYsm6HvSZDXLtMi+86yqvTuf7loJy6JvLBDzHGAA==", + "license": "Apache-2.0", + "dependencies": { + "@rollup/plugin-commonjs": "~22.0.2", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-core": ">=1", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-functions": ">=1", + "@stoplight/spectral-parsers": ">=1", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-ruleset-migrator": "^1.9.6", + "@stoplight/spectral-rulesets": ">=1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@types/node": "*", + "pony-cause": "1.1.1", + "rollup": "~2.80.0", + "tslib": "^2.8.1", + "validate-npm-package-name": "3.0.0" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ruleset-migrator/-/spectral-ruleset-migrator-1.12.1.tgz", + "integrity": "sha512-IUEbDmmTro0oF6VoAtrUySRV/b6bvYmV7wV6lB99f0Ym5lF9M2DXcgPLo7VMbKTPjCOQcaBzWRnIMXAyLjIRMA==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/ordered-object-literal": "~1.0.4", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-functions": "^1.9.1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@stoplight/yaml": "~4.2.3", + "@types/node": "*", + "ajv": "^8.18.0", + "ast-types": "0.14.2", + "astring": "^1.9.0", + "reserved": "0.1.2", + "tslib": "^2.8.1", + "validate-npm-package-name": "3.0.0" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.2.3.tgz", + "integrity": "sha512-Mx01wjRAR9C7yLMUyYFTfbUf5DimEpHMkRDQ1PKLe9dfNILbgdxyrncsOXM3vCpsQ1Hfj4bPiGl+u4u6e9Akqw==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.1", + "@stoplight/types": "^13.0.0", + "@stoplight/yaml-ast-parser": "0.0.48", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/spectral-ruleset-migrator/node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.48", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.48.tgz", + "integrity": "sha512-sV+51I7WYnLJnKPn2EMWgS4EUfoP4iWEbrWwbXsj0MZCB/xOK8j6+C9fntIdOM50kpx45ZLC3s6kwKivWuqvyg==", + "license": "Apache-2.0" + }, + "node_modules/@stoplight/spectral-rulesets": { + "version": "1.22.6", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-rulesets/-/spectral-rulesets-1.22.6.tgz", + "integrity": "sha512-xBwrb2zjx+7AzGS3aX7aOtddChRRw8aoQMu8ZT5AmTfEr0VAj4ydGC6Pl7lIvAIomcO7hw+P4I2oylTOOCkUVw==", + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/specs": "^6.8.0", + "@scarf/scarf": "^1.4.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-functions": "^1.9.1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@types/json-schema": "^7.0.7", + "ajv": "^8.18.0", + "ajv-formats": "~2.1.1", + "json-schema-traverse": "^1.0.0", + "leven": "3.1.0", + "lodash": "^4.18.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-rulesets/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@stoplight/spectral-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-runtime/-/spectral-runtime-1.1.6.tgz", + "integrity": "sha512-Y8rEDyMN4bSMJCrDs2shdcVHYyCnH3FvXRP4dBhha4Z8iJv+JPp7KqOV/hwVB/hWFC209upiwj2oDmLfR0qCDg==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.20.1", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "lodash": "^4.18.1", + "node-fetch": "^2.7.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/types": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.20.0.tgz", + "integrity": "sha512-2FNTv05If7ib79VPDA/r9eUet76jewXFH2y2K5vuge6SXbRHtWBhcaRmu+6QpF4/WRNoJj5XYRSwLGXDxysBGA==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.3.0.tgz", + "integrity": "sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w==", + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.5", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml-ast-parser": "0.0.50", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.50.tgz", + "integrity": "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ==", + "license": "Apache-2.0" + }, + "node_modules/@stoplight/yaml/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@types/es-aggregate-error": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz", + "integrity": "sha512-qJ7LIFp06h1QE1aVxbVd+zJP2wdaugYXYfd6JxsyRMrYHaxb6itXPogW2tz+ylUJ1n1b+JF1PHyYCfYHm0dvUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/markdown-escape": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@types/markdown-escape/-/markdown-escape-1.1.3.tgz", + "integrity": "sha512-JIc1+s3y5ujKnt/+N+wq6s/QdL2qZ11fP79MijrVXsAAnzSxCbT2j/3prHRouJdZ2yFLN3vkP0HytfnoCczjOw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/sarif": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", + "integrity": "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==", + "license": "MIT" + }, + "node_modules/@types/urijs": { + "version": "1.19.26", + "resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.26.tgz", + "integrity": "sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/as-table": { + "version": "1.0.55", + "resolved": "https://registry.npmjs.org/as-table/-/as-table-1.0.55.tgz", + "integrity": "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==", + "license": "MIT", + "dependencies": { + "printable-characters": "^1.0.42" + } + }, + "node_modules/ast-types": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.14.2.tgz", + "integrity": "sha512-O0yuUDnZeQDL+ncNGlJ78BiO4jnYI3bvMsD5prT0/nsgijG/LpNBIr63gTjVTNsiGkgQhiyCShTgxt8oXOrklA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/builtins": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/builtins/-/builtins-1.0.3.tgz", + "integrity": "sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==", + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-2.0.2.tgz", + "integrity": "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==", + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-aggregate-error": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/es-aggregate-error/-/es-aggregate-error-1.0.14.tgz", + "integrity": "sha512-3YxX6rVb07B5TV11AV5wsL7nQCHXNwoHPsQC8S4AmBiqYhyNCJ5BRKXkXyDJvs8QzXN20NgRtxe3dEEQD9NLHA==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "globalthis": "^1.0.4", + "has-property-descriptors": "^1.0.2", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expr-eval-fork": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/expr-eval-fork/-/expr-eval-fork-3.0.3.tgz", + "integrity": "sha512-BhC+hbc5lIVjygr840n5DEkW3MQq7H9o+mc1/N7Z5uIiCFVyESLL5DIE7LNq4CYUNxy+XjA+3jRrL/h0Kt2xcg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-source": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/get-source/-/get-source-2.0.12.tgz", + "integrity": "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==", + "license": "Unlicense", + "dependencies": { + "data-uri-to-buffer": "^2.0.0", + "source-map": "^0.6.1" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hpagent": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz", + "integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/jsonc-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.2.1.tgz", + "integrity": "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.topath": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz", + "integrity": "sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/markdown-escape": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-escape/-/markdown-escape-2.0.0.tgz", + "integrity": "sha512-Trz4v0+XWlwy68LJIyw3bLbsJiC8XAbRCKF9DbEtZjyndKOGVx6n+wNB0VfoRmY2LKboQLeniap3xrb6LGSJ8A==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/nimma": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/nimma/-/nimma-0.2.3.tgz", + "integrity": "sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA==", + "license": "Apache-2.0", + "dependencies": { + "@jsep-plugin/regex": "^1.0.1", + "@jsep-plugin/ternary": "^1.0.2", + "astring": "^1.8.1", + "jsep": "^1.2.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + }, + "optionalDependencies": { + "jsonpath-plus": "^6.0.1 || ^10.1.0", + "lodash.topath": "^4.5.2" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-sarif-builder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/node-sarif-builder/-/node-sarif-builder-2.0.3.tgz", + "integrity": "sha512-Pzr3rol8fvhG/oJjIq2NTVB0vmdNNlz22FENhhPojYRZ4/ee08CfK4YuKmuL54V9MLhI1kpzxfOJ/63LzmZzDg==", + "license": "MIT", + "dependencies": { + "@types/sarif": "^2.1.4", + "fs-extra": "^10.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pony-cause": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", + "integrity": "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==", + "license": "0BSD", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/printable-characters": { + "version": "1.0.42", + "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", + "integrity": "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==", + "license": "Unlicense" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reserved": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/reserved/-/reserved-0.1.2.tgz", + "integrity": "sha512-/qO54MWj5L8WCBP9/UNe2iefJc+L9yETbH32xO/ft/EYPOTCR5k+azvDUgdCOKwZH8hXwPd0b8XBL78Nn2U69g==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-stable-stringify": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-1.1.1.tgz", + "integrity": "sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "license": "MIT" + }, + "node_modules/stacktracey": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.2.0.tgz", + "integrity": "sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==", + "license": "Unlicense", + "dependencies": { + "as-table": "^1.0.36", + "get-source": "^2.0.12" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/validate-npm-package-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz", + "integrity": "sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==", + "license": "ISC", + "dependencies": { + "builtins": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + } + } +} diff --git a/api-design-guide/linter/package.json b/api-design-guide/linter/package.json new file mode 100644 index 0000000..2ec7921 --- /dev/null +++ b/api-design-guide/linter/package.json @@ -0,0 +1,26 @@ +{ + "name": "govstack-api-lint", + "version": "0.1.0-draft", + "private": true, + "description": "GovStack Spectral ruleset and lint driver for the Cross-BB API Design Guide", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "lint": "node cli.mjs", + "test": "COVERAGE_ENFORCE=1 node --test \"tests/*.test.mjs\"" + }, + "dependencies": { + "@stoplight/spectral-cli": "^6.16.1", + "@stoplight/spectral-core": "^1.23.0", + "@stoplight/spectral-functions": "^1.10.1", + "@stoplight/spectral-parsers": "^1.0.5", + "@stoplight/spectral-ruleset-bundler": "^1.6.2", + "yaml": "^2.8.0" + }, + "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1" + } +} diff --git a/api-design-guide/linter/ruleset.yaml b/api-design-guide/linter/ruleset.yaml new file mode 100644 index 0000000..09b98cd --- /dev/null +++ b/api-design-guide/linter/ruleset.yaml @@ -0,0 +1,41 @@ +# GovStack API Design Guide — Spectral ruleset (entry point) +# ============================================================ +# This ruleset mechanically enforces the GovStack Cross-BB API Design Guide. +# Guide version implemented: 0.1.0-draft +# Rule catalogue (source of truth): ../rules.yaml +# Coverage contract (rule -> status -> spectral rules): ./coverage.yaml +# +# It is composed of one fragment per guide section (rulesets/sNN.yaml). Each +# fragment is a standalone Spectral ruleset that declares its own +# `functionsDir: "../functions"` and `functions:` list; this has been verified +# to resolve through the `extends` chain both via the ruleset bundler +# (programmatic, used by the tests) and via the Spectral CLI. +# +# How to run: +# npx spectral lint -r ruleset.yaml path/to/openapi.yaml # normative rules +# npx spectral lint -r strict.yaml path/to/openapi.yaml # + opt-in strict heuristics +# npm test # fixtures + unit + harness +# +# Fragment sNN.yaml carries the machine-checkable rules for guide section NN. +# Not every guide rule has one: coverage.yaml records, per guide rule, whether +# it is enforced directly, enforced by proxy, or left to human review. +extends: + - ./rulesets/s02.yaml + - ./rulesets/s03.yaml + - ./rulesets/s04.yaml + - ./rulesets/s05.yaml + - ./rulesets/s06.yaml + - ./rulesets/s07.yaml + - ./rulesets/s08.yaml + - ./rulesets/s09.yaml + - ./rulesets/s10.yaml + - ./rulesets/s11.yaml + - ./rulesets/s12.yaml + - ./rulesets/s13.yaml + - ./rulesets/s14.yaml + - ./rulesets/s15.yaml + - ./rulesets/s16.yaml + - ./rulesets/s17.yaml + - ./rulesets/s18.yaml + - ./rulesets/s19.yaml + - ./rulesets/s20.yaml diff --git a/api-design-guide/linter/rulesets/s02.yaml b/api-design-guide/linter/rulesets/s02.yaml new file mode 100644 index 0000000..acea584 --- /dev/null +++ b/api-design-guide/linter/rulesets/s02.yaml @@ -0,0 +1,125 @@ +# Rules for §2 OpenAPI document standards — proof-of-concept section. +# +# Implements guide rules 2.1, 2.5, 2.6, 2.7. Source text: ../../rules.yaml. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. +# +# Custom functions: this fragment declares functionsDir + functions so the +# bundler (programmatic) and the CLI both resolve valuePattern from +# ../functions. This has been verified to work through an `extends` chain. +functionsDir: "../functions" +functions: + - valuePattern +rules: + # 2.1 [M] — this ruleset qualifies the published 3.1.0-3.1.2 patches only. + govstack-2.1: + description: "OpenAPI version must be a qualified 3.1 patch (3.1.0-3.1.2; guide 2.1, [M])." + message: "[2.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#21-openapi-31-required + severity: error + formats: [oas2, oas3] + given: $ + then: + function: schema + functionOptions: + schema: + type: object + required: [openapi] + properties: + openapi: + enum: ["3.1.0", "3.1.1", "3.1.2"] + + # 2.5 [M] — info MUST include title, version, and description. Contact is advisory. + # Split per the documented suffix convention: presence here, SemVer below. + # formats [oas3] so the info block is checked even on a wrong-version doc. + govstack-2.5: + description: "info block must include title, version, and description (guide 2.5, [M])." + message: "[2.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#25-complete-info-block + severity: error + formats: [oas3] + given: $.info + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [title, version, description] + properties: + title: { type: string, minLength: 1 } + description: { type: string, minLength: 1 } + contact: { type: object } + + # 2.5 [M] — info.version MUST be SemVer. Uses the shared valuePattern function. + govstack-2.5-semver: + description: "info.version must be a SemVer string (guide 2.5, [M])." + message: "[2.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#25-complete-info-block + severity: error + formats: [oas3] + given: $.info.version + then: + function: valuePattern + functionOptions: + name: info.version + match: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$' + + # 2.6 [M+R] — deterministic MUST clauses are blocking: servers is non-empty, + # URLs use HTTPS, avoid loopback, and do not duplicate /vN from path keys. + # Does NOT verify: that URLs are "meaningful"/not fake production domains, the + # SHOULD to use parameterised template URLs, or the MAY to label example.org + # defaults as non-production. Those are not mechanically decidable. + govstack-2.6: + description: "servers must be non-empty HTTPS URLs without loopback or duplicate /vN (guide 2.6, [M+R])." + message: "[2.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#26-meaningful-servers-block + severity: error + formats: [oas3_1] + given: $ + then: + function: schema + functionOptions: + schema: + type: object + required: [servers] + properties: + servers: + type: array + minItems: 1 + items: + type: object + required: [url] + properties: + url: + type: string + allOf: + - pattern: '^https://' + - not: + pattern: 'localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?' + - not: + pattern: '/v[0-9]+(?:/|$)' + + # 2.7 [M+R] — every operation MUST include a stable operationId and description. + # Casing, summary, and tags are advisory. + govstack-2.7: + description: "every operation must declare a stable operationId and description (guide 2.7, [M+R])." + message: "[2.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/2-openapi-document-standards.md#27-complete-operation-metadata + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,options,head,patch,trace]" + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [operationId, description] + properties: + operationId: + type: string + minLength: 1 + description: { type: string, minLength: 1 } + tags: + type: array + minItems: 1 diff --git a/api-design-guide/linter/rulesets/s03.yaml b/api-design-guide/linter/rulesets/s03.yaml new file mode 100644 index 0000000..dc3c2d5 --- /dev/null +++ b/api-design-guide/linter/rulesets/s03.yaml @@ -0,0 +1,149 @@ +# Rules for §3 AsyncAPI document standards — generated from the guide; see coverage.yaml. +# +# Implements guide rules 3.1, 3.5, 3.6, 3.7 (implemented) and 3.9 (partial-proxy). +# Source text: ../../rules.yaml. Severity + formats policy: ../README.md#rule-naming-and-severities. +# 3.2/3.3/3.4 are driver-status (file-tree/validator) and 3.8 is needs-context — not here. +# +# Custom functions: this fragment declares functionsDir + functions so both the +# bundler (programmatic) and the CLI resolve them from ../functions. +functionsDir: "../functions" +functions: + - valuePattern + - schemaPropertyNames + - s03-asyncOperation +rules: + # 3.1 [M] — the spec MUST declare an AsyncAPI 3 version qualified by this + # ruleset. 0.1.0-draft qualifies 3.0.0 and 3.1.0; 2.x and earlier MUST NOT. + # formats [aas2, aas3] so a 2.x document is still told to move to AsyncAPI 3. + govstack-3.1: + description: "AsyncAPI version must be a qualified AsyncAPI 3 version, 3.0.0 or 3.1.0 (guide 3.1, [M])." + message: "[3.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#31-asyncapi-300-required + severity: error + formats: [aas2, aas3] + given: $ + then: + function: schema + functionOptions: + schema: + type: object + required: [asyncapi] + properties: + asyncapi: + enum: ["3.0.0", "3.1.0"] + + # 3.5 [M] — info MUST include title, version, and description. Contact is advisory. + # Split per the suffix convention: presence here, SemVer below. + # formats [aas2, aas3] so the info block is checked even on a wrong-version doc. + govstack-3.5: + description: "AsyncAPI info block must include title, version, and description (guide 3.5, [M])." + message: "[3.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block + severity: error + formats: [aas2, aas3] + given: $.info + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [title, version, description] + properties: + title: { type: string, minLength: 1 } + description: { type: string, minLength: 1 } + contact: { type: object } + + # 3.5 [M] — info.version MUST be SemVer. Shared valuePattern function. + govstack-3.5-semver: + description: "AsyncAPI info.version must be a SemVer string (guide 3.5, [M])." + message: "[3.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#35-complete-asyncapi-info-block + severity: error + formats: [aas2, aas3] + given: $.info.version + then: + function: valuePattern + functionOptions: + name: info.version + match: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$' + + # 3.6 [M+R] — the file MUST declare non-empty servers, channels, operations and + # components.messages. The SHOULD (parameterised hosts) and MAY (reserved doc + # domains) sentences are advisory and not mechanically verified here. + govstack-3.6: + description: "servers, channels, operations and components.messages must all be non-empty (guide 3.6, [M+R])." + message: "[3.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#36-servers-channels-operations-and-messages + severity: error + formats: [aas3] + given: $ + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [servers, channels, operations, components] + properties: + servers: { type: object, minProperties: 1 } + channels: { type: object, minProperties: 1 } + operations: { type: object, minProperties: 1 } + components: + type: object + required: [messages] + properties: + messages: { type: object, minProperties: 1 } + + # 3.6 [M+R] — server definitions MUST NOT point at localhost/loopback. The + # broader "no personal machines / undocumented placeholders / fake production + # brokers" prohibition is not mechanically decidable and is not verified. + govstack-3.6-host: + description: "AsyncAPI server host must not be localhost/loopback (guide 3.6, [M+R])." + message: "[3.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#36-servers-channels-operations-and-messages + severity: error + formats: [aas3] + given: $.servers[*].host + then: + function: valuePattern + functionOptions: + name: server.host + notMatch: 'localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?' + + # 3.7 [M+R] — every operation MUST declare action(send/receive), description, + # a referenced channel, and >=1 referenced message that + # resolves to a message on that channel. resolved:false so the raw $ref + # pointers are visible for the channel-membership cross-check. The + # CloudEvents-ness of the message (a §17.6 concern) and the MAY about channel + # messages re-referencing components.messages are not verified here. + govstack-3.7: + description: "every AsyncAPI operation must declare action/description/channel/>=1 channel message (guide 3.7, [M+R])." + message: "[3.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#37-complete-asyncapi-operation-metadata + severity: error + formats: [aas3] + resolved: false + given: $ + then: + function: s03-asyncOperation + + # 3.9 [M] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: message payload schema property names are camelCase (a concrete + # §9.2 check applied to payload fields). Does NOT verify: that payloads parse + # as valid JSON Schema (that is 3.4 / the AsyncAPI validator, a driver check), + # the full §10 field-format battery, or payload conventions on non-JSON-Schema + # payload formats. + govstack-3.9: + description: "GovStack-owned AsyncAPI message payload properties should be camelCase (guide 3.9, [M], proxy for §9/§10)." + message: "[3.9][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/3-asyncapi-document-standards.md#39-json-schema-payload-conventions + severity: warn + formats: [aas3] + given: + - $.components.messages[*].payload + - $.channels[*].messages[*].payload + then: + function: schemaPropertyNames + functionOptions: + casing: camel diff --git a/api-design-guide/linter/rulesets/s04-strict.yaml b/api-design-guide/linter/rulesets/s04-strict.yaml new file mode 100644 index 0000000..3b3f9a5 --- /dev/null +++ b/api-design-guide/linter/rulesets/s04-strict.yaml @@ -0,0 +1,21 @@ +# STRICT-only rules for §4 — ships only via ../strict.yaml (opt-in). +# +# 4.4 [R] — STRICT-ONLY heuristic proxy, severity warn per strict-only policy. +# Flags operation descriptions that are byte-identical across >=2 distinct +# operations: a strong copy-paste signal (the guide cites this exact audit +# finding). Does NOT verify that a non-duplicated description is actually +# accurate for its operation - that needs reading the operation's real +# behaviour, which is not mechanically checkable. +functionsDir: "../functions" +functions: + - s04-duplicateDescriptions +rules: + govstack-4.4: + description: "operation descriptions must not be copy-pasted verbatim across operations (guide 4.4, [R], strict proxy)." + message: "[4.4][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/4-documentation-requirements.md#44-accurate-operation-descriptions + severity: warn + formats: [oas3_1, aas3] + given: $ + then: + function: s04-duplicateDescriptions diff --git a/api-design-guide/linter/rulesets/s04.yaml b/api-design-guide/linter/rulesets/s04.yaml new file mode 100644 index 0000000..b4f4e8d --- /dev/null +++ b/api-design-guide/linter/rulesets/s04.yaml @@ -0,0 +1,68 @@ +# Rules for §4 documentation and descriptions — generated from the guide; see coverage.yaml +# +# Implements guide rules 4.1, 4.2, 4.3. Source text: ../../rules.yaml. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. +# 4.4 is STRICT-ONLY and lives in s04-strict.yaml instead. +# +# §4 surface is "Universal": every rule here fires on both OpenAPI and +# AsyncAPI documents. Given targets combine OpenAPI-shaped and AsyncAPI-shaped +# JSONPaths / document branches; whichever doesn't apply to the current +# document format simply selects/finds nothing. +functionsDir: "../functions" +functions: + - schemaDescriptions + - s04-bodyExamplesEnums + - s04-noPlaceholderText +rules: + # 4.1 [M] — schema descriptions are conditional/advisory. This heuristic warns + # on every missing description because semantic obviousness is not decidable. + # mode "all". Scope: components.schemas plus inline request/response body + # schemas (OpenAPI) and message payload schemas (AsyncAPI). Parameter/header + # schemas are out of scope (not swept). + govstack-4.1: + description: "schemas should have useful descriptions where semantics are not obvious (guide 4.1, [M], proxy)." + message: "[4.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/4-documentation-requirements.md#41-useful-schema-descriptions + severity: warn + formats: [oas3_1, aas3] + given: + - $.components.schemas[*] + - $.paths[*][get,put,post,delete,options,head,patch,trace].requestBody.content[*].schema + - $.paths[*][get,put,post,delete,options,head,patch,trace].responses[*].content[*].schema + - $.components.messages[*].payload + then: + function: schemaDescriptions + functionOptions: { mode: all } + + # 4.2 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: every request/response body (and AsyncAPI message) declares + # >=1 example; every `enum` schema node carries a description. + # Does NOT verify: that an enum's description actually explains what each + # value means (only that some description is present) - the guide's own + # text acknowledges an example alone can be insufficient, but a vacuous + # description would still pass here. + govstack-4.2: + description: "request/response bodies need an example; enums need documented values (guide 4.2, [M+R], proxy)." + message: "[4.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/4-documentation-requirements.md#42-examples-for-bodies-and-enums + severity: warn + formats: [oas3_1, aas3] + given: $ + then: + function: s04-bodyExamplesEnums + + # 4.3 [M+R] — PROXY (bucket B), notched one step: MUST NOT -> warn. + # Verifies: no string anywhere in the document matches TBD / Lorem ipsum / + # the literal placeholder list "a, b, c". + # Does NOT verify: content copy-pasted from another BB with that BB's name + # still present (needs an external list of BB names), or test plans with + # literal placeholder steps (needs test-plan-structure context). + govstack-4.3: + description: "the spec must not contain placeholder text (guide 4.3, [M+R], proxy)." + message: "[4.3][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-a/4-documentation-requirements.md#43-no-placeholder-text + severity: warn + formats: [oas3_1, aas3] + given: $ + then: + function: s04-noPlaceholderText diff --git a/api-design-guide/linter/rulesets/s05-strict.yaml b/api-design-guide/linter/rulesets/s05-strict.yaml new file mode 100644 index 0000000..e559504 --- /dev/null +++ b/api-design-guide/linter/rulesets/s05-strict.yaml @@ -0,0 +1,50 @@ +# STRICT-only rules for §5 — noisy lexical heuristics, opt-in via +# ../strict.yaml. All at severity `warn` per +# ../README.md#rule-naming-and-severities, regardless of the guide rule's own +# strength. Source text: ../../rules.yaml. +functionsDir: "../functions" +functions: + - s05-pluralNoun + - s05-actionVerbs +rules: + # 5.2 [M+R] MUST — resource paths use plural nouns. Lexical "last word ends + # in s" heuristic; see functions/s05-pluralNoun.js for known false + # positives (e.g. "status") and false negatives (irregular plurals not + # listed). + govstack-5.2: + description: "Resource path segments should look like plural nouns (guide 5.2, [M+R], strict heuristic)." + message: "[5.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#52-plural-noun-resources + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: s05-pluralNoun + functionOptions: + exceptions: [health, ready] + + # 5.7 [M+R] MUST NOT — verbs in CRUD paths. Lexical dictionary heuristic; + # see functions/s05-actionVerbs.js. Shares its detector with 5.8 below (the + # guide states the same shape requirement as both a prohibition and a + # required positive shape). + govstack-5.7: + description: "Verbs must not appear in paths for CRUD operations (guide 5.7, [M+R], strict heuristic)." + message: "[5.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#57-no-verbs-in-crud-paths + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: s05-actionVerbs + + # 5.8 [R] MUST — non-CRUD actions expressed as /{collection}/{id}/{verb} + # sub-resources. + govstack-5.8: + description: "Non-CRUD actions should be expressed as /{collection}/{id}/{verb} sub-resources (guide 5.8, [R], strict heuristic)." + message: "[5.8][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#58-actions-as-sub-resources + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: s05-actionVerbs diff --git a/api-design-guide/linter/rulesets/s05.yaml b/api-design-guide/linter/rulesets/s05.yaml new file mode 100644 index 0000000..311718e --- /dev/null +++ b/api-design-guide/linter/rulesets/s05.yaml @@ -0,0 +1,185 @@ +# Rules for §5 URL structure and versioning. Source text: ../../rules.yaml. +# +# Implements guide rules 5.1, 5.3, 5.4, 5.6, 5.9, 5.10 (implemented) and 5.5 +# (partial-proxy). Strict-only siblings 5.2, 5.7, 5.8 live in +# rulesets/s05-strict.yaml. Severity policy and formats follow +# ../README.md#rule-naming-and-severities. +functionsDir: "../functions" +functions: + - pathSegments + - valuePattern + - mediaTypeExpected + - operationResponses + - securityCoverage +rules: + # 5.1 [M] SHOULD — major version prefix /v{N}/... on GovStack resource paths. The guide 5.10 + # standard unversioned endpoints cannot carry a /v{N}/ prefix and are skipped + # inside `pathSegments` (functions/lib/standardEndpoints.js), which is the one + # place that closed set is written down. + govstack-5.1: + description: "GovStack resource paths should start with a major version prefix /v{N}/ (guide 5.1, [M])." + message: "[5.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: pathSegments + functionOptions: { check: versionPrefix } + + # 5.3 [M] SHOULD — multi-word path segments should be kebab-case. + govstack-5.3: + description: "Path segments should be kebab-case (guide 5.3, [M])." + message: "[5.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#53-kebab-case-path-segments + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: pathSegments + functionOptions: { check: segmentCasing, casing: kebab } + + # 5.4 [M] SHOULD — path hierarchy shallow: at most two levels of nesting + # after /v{N}/. "Levels of nesting" counts resource/action segments only: + # `{param}` path parameters do NOT count. Guide 5.8/15.5 mandate + # POST /v1/operations/{operationId}/cancel and 16.11 mandates + # .../subscriptions/{subscriptionId}/rotate-secret (three raw segments, two + # non-param levels), so params must be skipped when counting depth. + govstack-5.4: + description: "Path hierarchy should have at most two levels of nesting after /v{N}/ (guide 5.4, [M])." + message: "[5.4][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#54-shallow-path-nesting + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: pathSegments + functionOptions: { check: maxDepthAfterVersion, max: 2 } + + # 5.5 [M+R] MUST — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: GET/DELETE operations do not declare a query parameter whose + # NAME looks identifier-shaped (exactly "id", camelCase "...Id", or + # snake_case "..._id"). + # Does NOT verify: the converse (that every identifier which SHOULD be a + # path parameter actually is one), identifier query params under other + # HTTP methods, or identifier-shaped names outside this lexical pattern. + # Pure name-shape heuristic; false positives are possible on legitimate + # non-identifying filter params that happen to end in "Id". + govstack-5.5: + description: "GET/DELETE must not identify a resource via an id-shaped query parameter (guide 5.5, [M+R], proxy)." + message: "[5.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#55-identifiers-as-path-parameters + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,delete].parameters[?(@.in=='query')].name" + then: + function: valuePattern + functionOptions: + name: "query parameter" + forbidPattern: '(^id$)|([a-z0-9]Id$)|(_id$)' + + # 5.6 [M] SHOULD — query parameter names follow the §9 JSON naming + # convention (camelCase). Covers both path-item-level (shared) and + # operation-level query parameters. + govstack-5.6: + description: "Query parameter names should be camelCase per §9 (guide 5.6, [M])." + message: "[5.6][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#56-query-parameter-naming + severity: warn + formats: [oas3_1] + given: + - "$.paths[*].parameters[?(@.in=='query')].name" + - "$.paths[*][get,put,post,delete,options,head,patch,trace].parameters[?(@.in=='query')].name" + then: + function: valuePattern + functionOptions: + name: "query parameter" + match: '^[a-z][a-zA-Z0-9]*$' + + # 5.9 [M+R] — mixed MUST NOT / MUST / MAY, split per sentence group (each + # its own severity per the mixed-strength convention; all checkable clauses + # here are MUST-level so all four are `error`). + # NOT checked: the MAY clause (a separate /ready endpoint is optional, so + # there is nothing to assert if absent) and "MUST NOT expose + # system-internal detail" (not mechanically decidable from the spec alone). + govstack-5.9-presence: + description: "An unversioned /health liveness endpoint must be exposed (guide 5.9, [M+R])." + message: "[5.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint + severity: error + formats: [oas3_1] + given: $.paths + then: + function: schema + functionOptions: + schema: + type: object + required: ["/health"] + + # Scoped to the SUCCESS (200) response only. Guide 5.9 gives the 200 body + # plain application/json and says the 503 is an ordinary error response, so + # guide 11.1 (4xx/5xx MUST be application/problem+json) owns the 503 and a + # responses[*] scope here would make the two unsatisfiable together. + govstack-5.9-media-type: + description: "/health 200 response must declare media type application/json (guide 5.9, [M+R])." + message: "[5.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint + severity: error + formats: [oas3_1] + given: "$.paths['/health'].get.responses['200'].content" + then: + function: mediaTypeExpected + functionOptions: + require: ['^application/json\s*(;|$)'] + + # Guide 5.9 carries health in the status code: 200 healthy, 503 temporarily + # unable to accept work. Both must be declared, or a consumer told to read the + # status code has no documented 503 to read. Applies to /ready on the same + # terms ("under the same rules"), and only when it is present (the MAY clause + # means an absent /ready is not a finding). + govstack-5.9-status-codes: + description: "/health (and /ready, if present) must declare both 200 and 503 responses (guide 5.9, [M+R])." + message: "[5.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint + severity: error + formats: [oas3_1] + given: + - "$.paths['/health'].get" + - "$.paths['/ready'].get" + then: + function: operationResponses + functionOptions: + require: ["200", "503"] + + govstack-5.9-no-auth: + description: "/health (and /ready, if present) must not require citizen authentication (guide 5.9, [M+R])." + message: "[5.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint + severity: error + formats: [oas3_1] + given: + - "$.paths['/health'].get" + - "$.paths['/ready'].get" + then: + function: securityCoverage + functionOptions: { mode: none } + + # 5.10 [M] — the closed set of standard unversioned endpoints. The exemptions + # the section grants are implemented where they bite (5.1 and 5.3 in + # `pathSegments`, §12 in `s12-collectionPagination`, 13.1 in + # `securityCoverage`), all reading the same set from + # functions/lib/standardEndpoints.js. What is left for a rule of its own is + # the prohibition: "a BB MUST NOT place a business resource under one of these + # paths ... they are read-only and MUST NOT declare POST, PUT, PATCH, or + # DELETE". Whether a GET under one of these paths is a business resource in + # disguise is not mechanically decidable and stays with review. + govstack-5.10: + description: "standard unversioned endpoints are read-only and must not declare a mutating method (guide 5.10, [M])." + message: "[5.10][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints + severity: error + formats: [oas3_1] + given: $.paths + then: + function: pathSegments + functionOptions: { check: standardEndpointsReadOnly } diff --git a/api-design-guide/linter/rulesets/s06.yaml b/api-design-guide/linter/rulesets/s06.yaml new file mode 100644 index 0000000..3980f55 --- /dev/null +++ b/api-design-guide/linter/rulesets/s06.yaml @@ -0,0 +1,97 @@ +# Rules for §6 HTTP methods — generated from the guide; see coverage.yaml +# +# Implements guide rules 6.1, 6.4, 6.5, 6.6, 6.7. Source text: ../../rules.yaml. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. +# surface: OpenAPI for all of §6 (these are HTTP-method semantics; AsyncAPI +# has no GET/PUT/PATCH/DELETE/POST), so every rule uses formats: [oas3_1]. +functionsDir: "../functions" +functions: + - mediaTypeExpected + - operationResponses + - s06-bulkMutationSelection +rules: + # 6.1 [M+R] — GET requests MUST NOT carry a body. (The "safe and + # idempotent" behavioural half of this rule is not mechanically checkable + # from a static document; only the request-body absence is enforced here.) + govstack-6.1: + description: "GET operations must not declare a requestBody (guide 6.1, [M+R])." + message: "[6.1][M+R] GET operations must not declare a requestBody; move any selection/filter criteria to query parameters." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#61-get-is-safe-and-idempotent + severity: error + formats: [oas3_1] + given: $.paths[*].get + then: + field: requestBody + function: undefined + + # 6.4 [M+R] — PATCH request bodies MUST use application/merge-patch+json; + # the RFC 6902 application/json-patch+json media type MAY additionally be + # present. Plain application/json (a common mistake, treating PATCH like + # PUT) is explicitly forbidden. Other/exotic media types are not + # enumerated here (not decidable which ones are "wrong" in general), so + # this forbid list is illustrative, not exhaustive. + govstack-6.4: + description: "PATCH request bodies should use application/merge-patch+json by default (guide 6.4, [M+R])." + message: "[6.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#64-patch-uses-a-registered-patch-format + severity: warn + formats: [oas3_1] + given: $.paths[*].patch.requestBody.content + then: + function: mediaTypeExpected + functionOptions: + require: ['application/merge-patch\+json'] + forbid: ['^application/json$'] + + # 6.5 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: DELETE only declares success statuses from {200, 202, 204} + # (forbids the common alternatives among the standard 2xx range), and that + # a 204 response (if declared) has no content/body. + # Does NOT verify: that a 200/202 response body actually "describes the + # resulting state" or is a genuine §15 Operation, nor which delete strategy + # (sync hard / soft / async) an implementation actually uses. + govstack-6.5: + description: "DELETE responses must be 204(no body)/200/202 (guide 6.5, [M+R], proxy)." + message: "[6.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#65-delete-response-semantics + severity: warn + formats: [oas3_1] + given: $.paths[*].delete + then: + - function: operationResponses + functionOptions: + forbid: ['201', '203', '205', '206', '207', '208', '226'] + - field: responses.204.content + function: undefined + + # 6.6 [M+R] — POST .../search MUST return 200, not 201. The given filters + # to path keys ending in "/search" via a JSONPath property filter (verified + # to resolve through both the bundler and the CLI). + govstack-6.6: + description: "POST .../search must return 200, not 201 (guide 6.6, [M+R])." + message: "[6.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#66-post-search-for-complex-queries + severity: error + formats: [oas3_1] + given: "$.paths[?(@property.endsWith('/search'))].post" + then: + function: operationResponses + functionOptions: + require: ['200'] + forbid: ['201'] + + # 6.7 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: a collection-targeted (no "{param}" segment) PUT/PATCH/DELETE + # declares >=1 query parameter (path-item- or operation-level). + # Does NOT verify: that the parameter actually scopes/limits which records + # get mutated, nor the append-only-resource carve-out (needs external + # knowledge of which resources are designated append-only). + govstack-6.7: + description: "collection-targeted PUT/PATCH/DELETE must declare an explicit selection parameter (guide 6.7, [M+R], proxy)." + message: "[6.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/6-http-methods.md#67-bulk-mutation-needs-explicit-selection + severity: warn + formats: [oas3_1] + given: "$.paths[?(!@property.includes('{'))]" + then: + function: s06-bulkMutationSelection diff --git a/api-design-guide/linter/rulesets/s07.yaml b/api-design-guide/linter/rulesets/s07.yaml new file mode 100644 index 0000000..308c610 --- /dev/null +++ b/api-design-guide/linter/rulesets/s07.yaml @@ -0,0 +1,242 @@ +# Rules for §7 HTTP methods and status codes — generated from the guide; see +# coverage.yaml. +# +# Implements guide rules 7.2, 7.3, 7.6, 7.13, 7.18, 7.19, 7.20 (mechanical +# checks) and 7.14, 7.16, 7.17 as partial-proxy (bucket B: severity notched +# one step below the rule's strongest normative keyword; each carries a +# comment on what it does NOT verify). Source text: ../../rules.yaml. +# Severity/formats policy: ../README.md#rule-naming-and-severities. +functionsDir: "../functions" +functions: + - responseHeaderRequired + - operationResponses + - s07-noStoreOnProblemJson + - s07-creationResponses + - s07-baselineResponses + - s07-successResponseSchema +rules: + # 7.2 [M] — every 201 response MUST include a Location header pointing at + # the created resource. Presence-only: does not verify the header's value + # actually resolves to the created resource. + govstack-7.2: + description: "every 201 response must declare a Location header (guide 7.2, [M])." + message: "[7.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#72-201-created-with-location + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: responseHeaderRequired + functionOptions: + status: "201" + headers: [Location] + + # 7.3 [M+R] — every 202 response MUST include Location pointing to an + # Operation resource (§15). Presence-only: does not verify the URL actually + # resolves to an Operation resource shape (govstack-15.x owns that). + govstack-7.3: + description: "every 202 response must declare a Location header (guide 7.3, [M+R])." + message: "[7.3][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#73-202-accepted-for-async-operations + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: responseHeaderRequired + functionOptions: + status: "202" + headers: [Location] + + # 7.6 [M+R] — every 401 response MUST include WWW-Authenticate (RFC 9110). + # Implements the MUST clause only; the SHOULD clause (RFC 6750 challenge + # with error=invalid_token for OAuth2 bearer schemes, §13.2) constrains the + # header's runtime *value*, not anything declared in the spec document. + govstack-7.6: + description: "every 401 response must declare a WWW-Authenticate header (guide 7.6, [M+R])." + message: "[7.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#76-401-with-www-authenticate + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: responseHeaderRequired + functionOptions: + status: "401" + headers: [WWW-Authenticate] + + # 7.13 [M] — specs MUST document a 500 response at minimum (502/503/504 are + # descriptive extras, not separately mandated). + govstack-7.13: + description: "every operation must declare a 500 response (guide 7.13, [M])." + message: "[7.13][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#713-server-errors-documented + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch]" + then: + function: operationResponses + functionOptions: + require: ["500"] + + # 7.14 [M] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: operation declares >=2 response codes AND >=1 non-2xx response, + # as evidence it does not declare "only 200". Does NOT verify that the + # declared codes are the actual set the operation can return, or that every + # relevant status is covered — "MUST declare the status codes it can + # return" is a claim about runtime behaviour a static document can't fully + # settle. + govstack-7.14: + description: "every operation must declare more than one status code, including a non-2xx (guide 7.14, [M], proxy)." + message: "[7.14][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#714-all-status-codes-declared + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch]" + then: + function: operationResponses + functionOptions: + minCount: 2 + minNonSuccess: 1 + + # 7.14 [M] — ADVISORY PROXY. A creation-like POST is identified by a + # create/register/add/submit/start/initiate operationId prefix and should + # declare either synchronous 201 or asynchronous 202 semantics. + govstack-7.14-creation-status: + description: "creation-like POST operations should declare 201 or 202 (guide 7.14, proxy)." + message: "[7.14][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#714-all-status-codes-declared + severity: warn + formats: [oas3_1] + given: $ + then: + function: s07-creationResponses + + # 7.14 [M] — deterministic structural baseline. Applicability comes from + # explicit OAS shape: nonempty security requires 401, scoped security 403, + # request bodies or path/query parameters 400, and templated paths 404. + govstack-7.14-baseline-errors: + description: "operations should declare mechanically applicable baseline errors (guide 7.14, proxy)." + message: "[7.14][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#714-all-status-codes-declared + severity: error + formats: [oas3_1] + given: $ + then: + function: s07-baselineResponses + + # 7.16 [M+R] — PROXY (bucket B), notched one step: SHOULD -> info. + # Verifies: every GET operation's 200 response declares an ETag header AND + # the operation also declares a 304 response. Does NOT distinguish + # single-resource GETs from collection-listing GETs (applies to every GET + # operation's 200 response), does not verify the ETag is actually derived + # from resource state, and does not verify client If-None-Match support + # (client-side behaviour, invisible to a server spec). + # The given excludes the guide 5.9 operational endpoints /health and /ready: + # 7.16 applies to "endpoints that return resources", and caching a liveness + # probe is an anti-pattern (no ETag/304 to advertise). + govstack-7.16: + description: "GET operations should declare an ETag header on 200 and a 304 response (guide 7.16, [M+R], proxy)." + message: "[7.16][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#716-etag-and-if-none-match + severity: info + formats: [oas3_1] + given: "$.paths[?(@property != '/health' && @property != '/ready')].get.responses" + then: + function: responseHeaderRequired + functionOptions: + status: "200" + headers: [ETag] + alsoRequireStatus: "304" + + # 7.17 [M+R] — PROXY (bucket B), notched one step: SHOULD -> info. + # Verifies: PUT/PATCH operations declare an If-Match header parameter (at + # the operation level — a parameter shared via the enclosing path item is + # not seen here) AND a 412 response. Does NOT verify the server actually + # implements optimistic concurrency, nor that 409 is reserved for + # non-precondition conflicts (7.9's territory). + govstack-7.17: + description: "PUT/PATCH operations should declare an If-Match header parameter and a 412 response (guide 7.17, [M+R], proxy)." + message: "[7.17][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#717-optimistic-concurrency-with-if-match + severity: info + formats: [oas3_1] + given: "$.paths[*][put,patch]" + then: + function: schema + functionOptions: + allErrors: true + schema: + type: object + required: [parameters, responses] + properties: + parameters: + type: array + contains: + type: object + properties: + name: { const: If-Match } + in: { const: header } + responses: + type: object + required: ["412"] + + # 7.18 [M] — every 405 response MUST include Allow listing the supported + # methods (RFC 9110). Presence-only: does not verify the Allow header's + # value actually lists the correct methods. + govstack-7.18: + description: "every 405 response must declare an Allow header (guide 7.18, [M])." + message: "[7.18][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#718-405-with-allow-header + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: responseHeaderRequired + functionOptions: + status: "405" + headers: [Allow] + + # 7.19 [M+R] — PATCH endpoints MUST return 415 for an unsupported patch + # media type (§6.4). The accompanying MAY clause (406 for Accept-header + # negotiation failures) is permissive and not tied to any specific + # operation type, so it is not checked. + govstack-7.19: + description: "every PATCH operation must declare a 415 response (guide 7.19, [M+R])." + message: "[7.19][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#719-415-for-unsupported-media-types + severity: error + formats: [oas3_1] + given: "$.paths[*].patch" + then: + function: operationResponses + functionOptions: + require: ["415"] + + # 7.20 [M] — SHOULD Cache-Control: no-store on error/Operation-status + # responses. Implements the mechanically clear half: any response whose + # content includes application/problem+json MUST declare a Cache-Control + # header (presence-only, not value). Does NOT independently identify + # "Operation-status responses" (§15) — those have no content-type + # signature to key off and require the Operation resource shape, which + # this section does not own. + govstack-7.20: + description: "problem+json responses should declare a Cache-Control header (guide 7.20, [M])." + message: "[7.20][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#720-no-store-on-error-responses + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses[*]" + then: + function: s07-noStoreOnProblemJson + + # 7.21 [M] — every declared 2xx response media type has a concrete schema; + # 204 responses do not declare content. + govstack-7.21: + description: "successful response bodies must declare non-empty schemas (guide 7.21, [M])." + message: "[7.21][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/7-http-status-codes.md#721-schemas-for-successful-response-bodies + severity: error + formats: [oas3_1] + given: $ + then: + function: s07-successResponseSchema diff --git a/api-design-guide/linter/rulesets/s08-strict.yaml b/api-design-guide/linter/rulesets/s08-strict.yaml new file mode 100644 index 0000000..0661bdb --- /dev/null +++ b/api-design-guide/linter/rulesets/s08-strict.yaml @@ -0,0 +1,26 @@ +# STRICT-only rules for §8 — implements guide rule 8.6. +# +# Ships only via ../strict.yaml (opt-in). See ../coverage.yaml and +# ../README.md#rule-naming-and-severities. +functionsDir: "../functions" +functions: + - s08-personalDataInUrl +rules: + # 8.6 [R] — STRICT-ONLY heuristic, severity warn (per convention, always + # warn regardless of the guide's own MUST/MUST NOT strength). + # Verifies: path/query/header parameters whose NAME matches a dictionary + # of personal-data term fragments (email, phone, nationalId, dateOfBirth, + # ...). Deliberately noisy: flags plausible names, not a definitive + # determination, hence STRICT-ONLY rather than shipped in the main ruleset. + # Does NOT verify: header/query VALUES (only names are visible + # statically), synonyms outside the dictionary, or whether an ID is + # actually opaque/server-generated. + govstack-8.6: + description: "path/query/header parameter names must not look like personal data (guide 8.6, [R], strict heuristic)." + message: "[8.6][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#86-no-personal-data-in-addressable-locations + severity: warn + formats: [oas3_1] + given: $ + then: + function: s08-personalDataInUrl diff --git a/api-design-guide/linter/rulesets/s08.yaml b/api-design-guide/linter/rulesets/s08.yaml new file mode 100644 index 0000000..f9ede83 --- /dev/null +++ b/api-design-guide/linter/rulesets/s08.yaml @@ -0,0 +1,122 @@ +# Rules for §8 HTTP headers — implements guide rules 8.1-8.5, 8.7. +# +# 8.6 is STRICT-ONLY; see ./s08-strict.yaml. Source text: ../../rules.yaml. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. +# +# Custom functions: none of the shared library functions (functions/README.md) +# combine "operation parameters" with "responses" or scan header names across +# the whole document, so this fragment adds section-local functions for those +# cross-cutting checks. Each is documented inline in its file. +functionsDir: "../functions" +functions: + - s08-credentialsInUrl + - s08-headerEcho + - s08-idempotencyKeyRequired + - s08-traceContext + - s08-noXHeaders + - s08-rateLimitHeaders +rules: + # 8.1 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: no apiKey security scheme is placed in query/cookie, and no + # path/query parameter has a credential-looking name (token, apiKey, + # secret, password, credential, ...). + # Does NOT verify: that credentials actually travel via the Authorization + # header at runtime, arbitrary/synonym parameter names, URL fragments (not + # representable in OpenAPI), or header VALUES that might embed a credential. + govstack-8.1: + description: "credentials must not travel in query/path parameters or an apiKey query/cookie scheme (guide 8.1, [M+R], proxy)." + message: "[8.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#81-credentials-in-authorization-header + severity: warn + formats: [oas3_1] + given: $ + then: + function: s08-credentialsInUrl + + # 8.2 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: IF an operation declares an Accept-Language header parameter + # (a "localisation request"), its 2xx responses declare Content-Language. + # Does NOT verify: requests that omit Accept-Language, or that the echoed + # value actually reflects the negotiated language. + govstack-8.2: + description: "operations accepting Accept-Language must echo Content-Language on success responses (guide 8.2, [M+R], proxy)." + message: "[8.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#82-accept-language-and-content-language + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch]" + then: + function: s08-headerEcho + functionOptions: + requestHeader: Accept-Language + responseHeader: Content-Language + + # 8.3 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: a POST operation declaring a 201 response (proxy for "requires + # idempotency under §14") accepts an Idempotency-Key header parameter. + # Does NOT verify: the actual §14 idempotency-requirement determination, + # nor the §14.6 opt-out. + govstack-8.3: + description: "create-POSTs (201) must accept an Idempotency-Key header (guide 8.3, [M+R], proxy)." + message: "[8.3][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#83-idempotency-key-header-accepted + severity: warn + formats: [oas3_1] + given: "$.paths[*].post" + then: + function: s08-idempotencyKeyRequired + + # 8.4 [M+R] — cross-service proxy: operations with effective nonempty + # security declare the W3C traceparent request header. Explicit security: [] + # operations such as /health are excluded. Runtime propagation is review/test. + govstack-8.4: + description: "secured cross-service operations should declare traceparent (guide 8.4, [M+R], proxy)." + message: "[8.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#84-w3c-trace-context-correlation + severity: warn + formats: [oas3_1] + given: $ + then: + function: s08-traceContext + + # 8.5 [M] — implemented (bucket A). New custom headers (parameters and + # response headers) must not use the X- prefix. + govstack-8.5: + description: "new custom headers must not use the X- prefix (guide 8.5, [M])." + message: "[8.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#85-no-new-x--prefixed-headers + severity: error + formats: [oas3_1] + given: $ + then: + function: s08-noXHeaders + + # 8.7 [M+R] — PROXY (bucket B), notched one step: MUST -> warn. + # Verifies: an operation declaring a 429 response (proxy for "rate-limited + # by the BB itself") declares Retry-After on 429 and the structured + # RateLimit header on its 2xx and 429 responses. + # Does NOT verify: that rate limiting is actually implemented, the + # delegated-to-gateway prose exception or the header's runtime value. + govstack-8.7: + description: "rate-limited endpoints must declare structured RateLimit and 429 Retry-After (guide 8.7, [M+R], proxy)." + message: "[8.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#87-rate-limit-headers-declared + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: s08-rateLimitHeaders + + # 8.7 [M+R] — deterministic MUST NOT: the legacy three-header variant is + # not conformant to the pinned structured-field draft. + govstack-8.7-no-legacy: + description: "legacy RateLimit-Limit/Remaining/Reset headers are forbidden (guide 8.7, [M+R])." + message: "[8.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-b/8-headers.md#87-rate-limit-headers-declared + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: s08-rateLimitHeaders + functionOptions: + forbidLegacy: true diff --git a/api-design-guide/linter/rulesets/s09-strict.yaml b/api-design-guide/linter/rulesets/s09-strict.yaml new file mode 100644 index 0000000..7a6791f --- /dev/null +++ b/api-design-guide/linter/rulesets/s09-strict.yaml @@ -0,0 +1,37 @@ +# STRICT-only rules for §9 — ships only via ../strict.yaml (opt-in). +# +# Holds the deliberately-noisy §9 heuristics (9.6 abbreviation dictionary, 9.10 +# GovStack extension-prefix guess). Both run at severity warn. Source text: +# ../../rules.yaml. +functionsDir: "../functions" +functions: + - s09-abbreviations + - s09-extensionPrefix +rules: + # 9.6 [R] — STRICT proxy. Flags property-name words found in an abbreviation + # dictionary (qty -> quantity, addr -> address, …). Noisy by design and + # dictionary-bounded: misses abbreviations not in the list, may flag words + # that legitimately look like an abbreviation. + govstack-9.6: + description: "Abbreviations should not be used in field names (guide 9.6, [R], strict proxy)." + message: "[9.6][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#96-avoid-abbreviations + severity: warn + formats: [oas3_1] + given: $.components.schemas[*] + then: + function: s09-abbreviations + + # 9.10 [M+R] — STRICT proxy. Flags x-* extensions that look GovStack-defined + # but are not prefixed exactly x-govstack- (a known concept like x-delivery, + # or any key mentioning govstack). Weak: cannot know the full GovStack + # extension set, cannot tell third-party x- extensions apart. + govstack-9.10: + description: "GovStack-defined extensions must use the x-govstack- prefix (guide 9.10, [M+R], strict proxy)." + message: "[9.10][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#910-govstack-extension-prefix + severity: warn + formats: [oas3_1, aas3] + given: $ + then: + function: s09-extensionPrefix diff --git a/api-design-guide/linter/rulesets/s09.yaml b/api-design-guide/linter/rulesets/s09.yaml new file mode 100644 index 0000000..f45410c --- /dev/null +++ b/api-design-guide/linter/rulesets/s09.yaml @@ -0,0 +1,170 @@ +# Rules for §9 JSON conventions and naming — generated from the guide. +# +# Implements guide rules 9.1-9.5, 9.7-9.9, 9.11 (9.6 and 9.10 are STRICT-only, +# in rulesets/s09-strict.yaml). Source text: ../../rules.yaml. Severity and +# formats follow ../README.md#rule-naming-and-severities. +# +# Bucket-B proxies (9.1, 9.3, 9.4, 9.7, 9.9, 9.11) run one severity notch below +# the rule's strength and each carry a comment saying what they do NOT verify. +functionsDir: "../functions" +functions: + - mediaTypeExpected + - schemaPropertyNames + - s09-booleanStrings + - s09-enumCasing + - s09-closedEnum + - s09-bbCode +aliases: + DataSchemas: + description: "Reusable and inline OpenAPI body schemas plus AsyncAPI message payload schemas." + targets: + - formats: [oas3_1] + given: + - $.components.schemas[*] + - "$.paths[*][get,put,post,delete,options,head,patch,trace].requestBody.content[*].schema" + - "$.paths[*][get,put,post,delete,options,head,patch,trace].responses[*].content[*].schema" + - "$.webhooks[*][get,put,post,delete,options,head,patch,trace].requestBody.content[*].schema" + - "$.webhooks[*][get,put,post,delete,options,head,patch,trace].responses[*].content[*].schema" + - formats: [aas3] + given: + - $.components.messages[*].payload + - $.channels[*].messages[*].payload +rules: + # 9.1 [M+R] — PROXY (bucket B), MUST -> warn. Every response body should offer + # a JSON media type (application/json or a +json family type, e.g. + # problem+json). Does NOT verify the "unless binary or a document export" + # exception, so a legitimately binary response (application/pdf, image/*) is + # flagged; treat as a prompt to confirm the exception applies. + govstack-9.1: + description: "Response bodies should offer a JSON media type (guide 9.1, [M+R], proxy)." + message: "[9.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#91-json-as-default-media-type + severity: warn + formats: [oas3_1] + given: $.paths[*][get,put,post,delete,patch,head,options].responses[*].content + then: + function: mediaTypeExpected + functionOptions: + requireOneOf: ['json'] + + # 9.2 [M] — GovStack-owned property names SHOULD be camelCase. + govstack-9.2: + description: "GovStack-owned JSON field names should be camelCase (guide 9.2, [M])." + message: "[9.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#92-camelcase-field-names + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaPropertyNames + functionOptions: + casing: camel + + # 9.3 [M] — PROXY (bucket B), MUST -> warn. Flags boolean-looking fields typed + # string, and enums of the string literals "true"/"false". Does NOT catch + # boolean values typed as integer 0/1, nor boolean-valued strings with a + # non-boolean-ish name (indistinguishable from real strings). + govstack-9.3: + description: "Boolean fields must be real JSON booleans, not strings (guide 9.3, [M], proxy)." + message: "[9.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#93-real-json-booleans + severity: warn + formats: [oas3_1] + given: $.components.schemas[*] + then: + function: s09-booleanStrings + + # 9.4 [M] — the presence of the removed OpenAPI 3.0 `nullable` keyword is a + # deterministic violation: `nullable` is not a 3.1 keyword; nullability must + # be expressed as `type: [..., "null"]`. Restricted to 3.1 docs (formats: + # oas3_1). Does NOT verify that every genuinely-nullable field declares the + # null type. + govstack-9.4: + description: "Nullability must be explicit via type: [..., null], not 3.0 nullable (guide 9.4, [M], proxy)." + message: "[9.4][M] Do not use OpenAPI 3.0 'nullable'; express nullability as type: [..., \"null\"] (§9.4)." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#94-explicit-nullability + severity: error + formats: [oas3_1] + given: $..nullable + then: + function: undefined + + # 9.5 [M] — GovStack-owned field names SHOULD use ASCII without spaces. + govstack-9.5: + description: "GovStack-owned field names should use ASCII without spaces (guide 9.5, [M])." + message: "[9.5][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#95-no-spaces-or-non-ascii-names + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaPropertyNames + functionOptions: + forbidPattern: '\s|[^\x00-\x7F]' + + # 9.7 [M] — PROXY (bucket B), MUST -> warn. Flags string enum members that are + # not SCREAMING_SNAKE_CASE, minus the carve-outs the rule lists (BCP 47 tags, + # reverse-DNS error codes and event types, x-govstack-* vocabularies, health + # status). A few genuine violations that happen to look ISO-like are missed, + # and §12.7 sort keys are lowerCamelCase field names indistinguishable from a + # mis-cased state name, so a sort-key schema is a known false positive. + govstack-9.7: + description: "BB-defined enum values should be SCREAMING_SNAKE_CASE (guide 9.7, [M], proxy)." + message: "[9.7][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#97-screaming-snake-case-enum-values + severity: warn + formats: [oas3_1] + given: $.components.schemas[*] + then: + function: s09-enumCasing + + # 9.8 [M] — resource body schemas MUST NOT set additionalProperties: false at + # the top level (it blocks forward-compatible field additions). Checks the + # top level of request/response body schemas; nested objects may still close. + govstack-9.8: + description: "Resource body schemas must not set additionalProperties: false at the top level (guide 9.8, [M])." + message: "[9.8][M] Resource body schema must not set additionalProperties: false at the top level (§9.8: forward-compatibility)." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas + severity: error + formats: [oas3_1] + given: + - $.paths[*][get,put,post,delete,patch].requestBody.content[*].schema + - $.paths[*][get,put,post,delete,patch].responses[*].content[*].schema + then: + function: schema + functionOptions: + schema: + type: object + properties: + additionalProperties: + not: + const: false + + # 9.9 [R] — PROXY (bucket B), SHOULD -> info. Flags closed string enums with + # no x-extensible-enum annotation and no UNKNOWN/OTHER fallback member. CANNOT + # tell whether a set is "expected to grow", so truly-fixed enums that §9.9 + # permits are also flagged; reviewer judgement required (hence info). + govstack-9.9: + description: "Growing-set fields should not be closed enums without an escape hatch (guide 9.9, [R], proxy)." + message: "[9.9][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#99-no-closed-enums-for-growing-sets + severity: info + formats: [oas3_1] + given: $.components.schemas[*] + then: + function: s09-closedEnum + + # 9.11 [M+R] — PROXY (bucket B), MUST -> warn. IN-DOCUMENT scope only: checks + # that BB codes embedded in OAuth scopes / reverse-DNS identifiers match the + # regex and are used identically within this document. Does NOT verify + # ecosystem-wide uniqueness (needs the cross-repo BB-code register, out of + # scope). Runs on OpenAPI and AsyncAPI documents. + govstack-9.11: + description: "BB codes must match the regex and be used identically in-document (guide 9.11, [M+R], proxy)." + message: "[9.11][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code + severity: warn + formats: [oas3_1, aas3] + given: $ + then: + function: s09-bbCode diff --git a/api-design-guide/linter/rulesets/s10.yaml b/api-design-guide/linter/rulesets/s10.yaml new file mode 100644 index 0000000..fa7f631 --- /dev/null +++ b/api-design-guide/linter/rulesets/s10.yaml @@ -0,0 +1,296 @@ +# Rules for §10 data types and formats — generated from the guide; see coverage.yaml +# +# Implements guide rules 10.1-10.11. Source text: ../../rules.yaml. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. +# +# Rules 10.1-10.10 are bucket-B PROXIES: the guide's `surface` for every 10.x +# rule is "Universal" (OpenAPI request/response bodies AND AsyncAPI message +# payloads), but a linter can only see property NAMES and their declared JSON +# Schema keywords, not what the field actually means or what data flows through +# it at runtime. Each rule below picks a name heuristic (e.g. properties ending +# in "Id", "At", "Date") and asserts the matching property's schema declares +# the mechanically-checkable part of the guide's MUST. Per the severity policy, +# every proxy here runs one notch below its class's strength: all ten rules are +# MUST-strength in rules.yaml, so all run at `warn` (not `error`). +# +# 10.11 is NOT a proxy and is not covered by that paragraph: a charset parameter +# on a declared media type is specification text a linter can read directly, so +# it is checked exactly and runs at `error`. +# +# Shared `given`: every rule scans the same set of schema-bearing locations, +# via the `DataSchemas` alias below — components.schemas / inline OpenAPI +# request+response body schemas for oas3_1 docs, components.messages/channel +# message payloads for aas3 docs. schemaFieldFormat's underlying walker +# recurses into nested properties/allOf/items/$defs, so declaring the schema +# root is enough to reach nested fields too. +functionsDir: "../functions" +functions: + - schemaFieldFormat + - mediaTypeExpected +aliases: + DataSchemas: + description: "Every JSON Schema node that may declare data-type fields: OpenAPI reusable/inline body schemas, or AsyncAPI message payloads." + targets: + - formats: [oas3_1] + given: + - $.components.schemas[*] + - "$.paths[*][get,put,post,delete,options,head,patch,trace].requestBody.content[*].schema" + - "$.paths[*][get,put,post,delete,options,head,patch,trace].responses[*].content[*].schema" + - formats: [aas3] + given: + - $.components.messages[*].payload + - $.channels[*].messages[*].payload +rules: + # 10.1 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: resource identifiers MUST be opaque, URL-safe, server-generated, + # globally-unique strings; UUID v4 SHOULD be the default; ULID/KSUID/other + # opaque ids MAY be used; statutory identifiers MAY be exempted from the + # opacity requirement subject to §8.6. This proxy only checks that + # properties named "id" or ending in "Id" declare type: string. It does NOT + # verify opacity, URL-safety, global uniqueness, server-generation, that a + # UUID (if used) is v4, or the statutory-identifier §8.6 exception. + govstack-10.1: + description: "Properties named id/*Id must declare type: string, not an integer (guide 10.1, [M+R], proxy)." + message: "[10.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(^id$|Id$)' + require: { type: string } + + # 10.2 [M] — PROXY (bucket B), MUST -> warn. + # Guide: timestamps MUST be RFC 3339 with timezone, declared as + # format: date-time. Proxy: properties ending in "At" or "Timestamp" + # (createdAt, updatedAt, eventTimestamp) must declare type: string, + # format: date-time. Does NOT verify the value itself is RFC 3339 with an + # explicit timezone offset (that's a data-instance check, out of scope for a + # schema-shape linter) or catch timestamp fields using other naming + # conventions. + govstack-10.2: + description: "Properties ending in At/Timestamp must declare type: string, format: date-time (guide 10.2, [M], proxy)." + message: "[10.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#102-rfc-3339-timestamps + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(At|Timestamp)$' + require: { type: string, format: date-time } + + # 10.3 [M] — PROXY (bucket B), MUST -> warn. + # Guide: dates without time MUST be RFC 3339 calendar dates, declared as + # format: date. Proxy: properties ending in "Date" (birthDate, dueDate) must + # declare type: string, format: date. Does NOT verify the value itself is a + # valid RFC 3339 calendar date, or catch date fields under other naming + # conventions. + govstack-10.3: + description: "Properties ending in Date must declare type: string, format: date (guide 10.3, [M], proxy)." + message: "[10.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#103-rfc-3339-calendar-dates + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: 'Date$' + require: { type: string, format: date } + + # 10.4 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: monetary amounts MUST use the object { amount: string (decimal), + # currency: string (ISO 4217) }; floats MUST NOT be used for money. Proxy: + # properties ending in "amount"/"price"/"balance" (case-insensitive) must + # not declare type: number or type: integer. This flags a money field typed + # as a bare number (float or minor-units integer) but does NOT verify the + # replacement is actually the { amount, currency } object shape, that + # `amount` is a decimal string, or that `currency` is present/ISO 4217 + # (that last part is 10.10's job on the "currency"-named sibling). + govstack-10.4: + description: "Properties ending in amount/price/balance must not declare type: number or integer (guide 10.4, [M+R], proxy)." + message: "[10.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#104-decimal-string-monetary-amounts + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(amount|price|balance)$' + nameFlags: i + require: { forbidType: [number, integer] } + + # 10.5 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: phone numbers MUST be E.164 strings. Proxy: properties whose name + # contains "phone" (case-insensitive) must declare type: string and an + # E.164-shaped pattern (^\+[1-9]\d{1,14}$). Does NOT verify the number is a + # real, dialable E.164 number, only that the schema's `pattern` keyword is + # exactly this regex. + govstack-10.5: + description: "Properties matching *phone* must declare type: string with an E.164 pattern (guide 10.5, [M+R], proxy)." + message: "[10.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#105-e164-phone-numbers + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: 'phone' + nameFlags: i + require: { type: string, pattern: '^\+[1-9]\d{1,14}$' } + + # 10.6 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: email addresses MUST be RFC 5322 strings, declared as + # format: email. Proxy: properties whose name contains "email" + # (case-insensitive) must declare type: string, format: email. Does NOT + # verify the value is actually a deliverable/valid RFC 5322 address. + govstack-10.6: + description: "Properties matching *email* must declare type: string, format: email (guide 10.6, [M+R], proxy)." + message: "[10.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#106-rfc-5322-email-addresses + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: 'email' + nameFlags: i + require: { type: string, format: email } + + # 10.7 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: large binary uploads MUST use multipart/form-data or a dedicated + # binary endpoint; small inline payloads (signatures, certificates, QR + # codes, attestations) MAY be base64-encoded in JSON bodies, but if so the + # field MUST declare contentEncoding: base64 and a documented size limit. + # This proxy only checks the base64-in-JSON half: properties named after + # the guide's own examples (signature, certificate, qrCode, attestation) + # must declare type: string, contentEncoding: base64, and a maxLength. It + # does NOT verify the large-binary-upload MUST (multipart/dedicated + # endpoint), does not catch base64 fields under other names, and treats + # "a documented size limit" as satisfied by any maxLength value rather than + # checking it is a reasonable bound. + govstack-10.7: + description: "Base64 fields (signature/certificate/qrCode/attestation) must declare contentEncoding: base64 and maxLength (guide 10.7, [M+R], proxy)." + message: "[10.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#107-binary-uploads-and-base64-payloads + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(signature|certificate|qrCode|qr_code|attestation)' + nameFlags: i + require: { type: string, contentEncoding: base64, mustDeclare: [maxLength] } + + # 10.8 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: country codes MUST be ISO 3166-1 alpha-2. Proxy: properties whose + # name contains "country" (case-insensitive) must declare type: string and + # the alpha-2 shape pattern ^[A-Z]{2}$. Does NOT verify the two letters are + # an actual assigned ISO 3166-1 code (vs. any two uppercase letters), and a + # schema that instead constrains the value via an `enum` of valid codes + # (with no `pattern` keyword) will be flagged even though it is compliant — + # a known false-positive of this proxy. + govstack-10.8: + description: "Properties matching *country* must declare type: string with an ISO 3166-1 alpha-2 pattern (guide 10.8, [M+R], proxy)." + message: "[10.8][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#108-iso-3166-1-country-codes + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: 'country' + nameFlags: i + require: { type: string, pattern: '^[A-Z]{2}$' } + + # 10.9 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: language codes MUST be BCP 47. Proxy: properties whose name + # contains "language" or "locale" (case-insensitive) must declare + # type: string and a common-case BCP 47 shape pattern (primary subtag, + # optional script, optional region/UN M49 area). Does NOT implement the + # full BCP 47 grammar (extended language subtags, variants, extensions, + # private-use subtags), only the common language[-script][-region] shape. + govstack-10.9: + description: "Properties matching *language*/*locale* must declare type: string with a BCP 47 shape pattern (guide 10.9, [M+R], proxy)." + message: "[10.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#109-bcp-47-language-codes + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(language|locale)' + nameFlags: i + require: { type: string, pattern: '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$' } + + # 10.10 [M+R] — PROXY (bucket B), MUST -> warn. + # Guide: currency codes MUST be ISO 4217. Proxy: properties named "currency" + # or ending in the camelCase word "Currency" (e.g. baseCurrency, + # currencyCode) must declare type: string and the alpha-3 shape pattern + # ^[A-Z]{3}$. Deliberately NOT a plain unanchored "contains currency" + # match: that would also catch unrelated fields like a "concurrencyToken" + # optimistic-locking field. Does NOT verify the three letters are an + # actually-assigned ISO 4217 code, and (like 10.8) a schema using an `enum` + # of valid codes instead of `pattern` is a known false-positive. + govstack-10.10: + description: "Properties matching currency/*Currency* must declare type: string with an ISO 4217 alpha-3 pattern (guide 10.10, [M+R], proxy)." + message: "[10.10][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes + severity: warn + formats: [oas3_1, aas3] + given: "#DataSchemas" + then: + function: schemaFieldFormat + functionOptions: + namePattern: '(^currency|Currency)' + require: { type: string, pattern: '^[A-Z]{3}$' } + + # 10.11 [M] — OpenAPI half. No declared media type may carry a charset + # parameter naming an encoding other than UTF-8. The character classes spell + # the match case-insensitively because functionOptions patterns take no flags. + # Payload bytes are a runtime property and are not checked here; this rule + # catches only the declared contract. + govstack-10.11-openapi: + description: "No declared media type may set charset to an encoding other than UTF-8 (guide 10.11, [M])." + message: "[10.11][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#1011-utf-8-text-encoding + severity: error + formats: [oas3_1] + given: + - $.paths[*][get,put,post,delete,options,head,patch,trace].requestBody.content + - $.paths[*][get,put,post,delete,options,head,patch,trace].responses[*].content + - $.webhooks[*][get,put,post,delete,options,head,patch,trace].requestBody.content + - $.webhooks[*][get,put,post,delete,options,head,patch,trace].responses[*].content + - $.components.requestBodies[*].content + - $.components.responses[*].content + then: + function: mediaTypeExpected + functionOptions: + forbid: '[Cc][Hh][Aa][Rr][Ss][Ee][Tt]\s*=\s*"?(?![Uu][Tt][Ff]-8)' + + # 10.11 [M] — AsyncAPI half. Same constraint on the contentType strings, which + # are plain values rather than map keys. + govstack-10.11-asyncapi: + description: "No declared contentType may set charset to an encoding other than UTF-8 (guide 10.11, [M])." + message: "[10.11][M] contentType must not set charset to an encoding other than UTF-8 (§10.11)." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/10-data-types-and-formats.md#1011-utf-8-text-encoding + severity: error + formats: [aas3] + given: + - $.defaultContentType + - $.components.messages[*].contentType + - $.channels[*].messages[*].contentType + then: + function: pattern + functionOptions: + notMatch: '[Cc][Hh][Aa][Rr][Ss][Ee][Tt]\s*=\s*"?(?![Uu][Tt][Ff]-8)' diff --git a/api-design-guide/linter/rulesets/s11.yaml b/api-design-guide/linter/rulesets/s11.yaml new file mode 100644 index 0000000..595b4a1 --- /dev/null +++ b/api-design-guide/linter/rulesets/s11.yaml @@ -0,0 +1,92 @@ +# Rules for §11 error handling — generated from the guide; see coverage.yaml. +# +# Implements the mechanically checkable portions of guide rules 11.1-11.4. +# Field-error applicability and identifier stability remain review/runtime +# concerns as documented in coverage.yaml. +# +# Surface for 11.1-11.5 is "Universal" in rules.yaml, but the mechanism below +# (HTTP status codes, `content` media types) only maps onto the OpenAPI +# surface: AsyncAPI has no equivalent request/response status-code model, so +# these rules are implemented for oas3_1 only. +functionsDir: "../functions" +functions: + - mediaTypeExpected + - envelopeShape + - s11-fieldErrors + - s11-problemType +rules: + # 11.1 [M] — error responses (4xx/5xx) MUST use application/problem+json. + # "Error responses" is read as any response declared under a 4xx or 5xx + # status key; a `default` response is not assumed to be an error and is not + # checked here. + govstack-11.1: + description: "4xx/5xx responses must use application/problem+json (guide 11.1, [M])." + message: "[11.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#111-rfc-9457-problem-details + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content" + then: + function: mediaTypeExpected + functionOptions: + require: ['application/problem\+json'] + + # 11.2 [M+R] — the RFC 9457 fields type/title/status MUST be present and the + # duplicate identifier `code` and redundant `timestamp` MUST NOT be added. + # `detail`/`instance` are SHOULD ("when they add diagnostic value") and the + # no-PII declaration is a documentation/human concern. + govstack-11.2: + description: "problem+json schema must declare type, title, status (guide 11.2, [M+R])." + message: "[11.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#112-stable-http-problem-type-uri + severity: error + formats: [oas3_1] + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema' + then: + function: envelopeShape + functionOptions: + requiredProperties: [type, title, status] + forbiddenProperties: [code, timestamp] + + # 11.3 [M] — traceId is the sole required GovStack extension. `resolved` + # defaults to true, so a shared Problem schema referenced from the vendored + # common file is inspected after reference resolution. + govstack-11.3: + description: "problem+json schema must declare traceId (guide 11.3, [M])." + message: "[11.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#113-trace-identifier + severity: error + formats: [oas3_1] + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema' + then: + function: envelopeShape + functionOptions: + requiredProperties: [traceId] + + # 11.4 [M+R] — when a problem schema opts into field-level errors, the array + # MUST be required and each item MUST contain pointer/message. This rule does + # NOT infer field attribution from an HTTP status code: an ordinary 400 may + # use Problem without errors, while a 422 may legitimately use + # ValidationProblem. Deciding when field errors apply requires review. + govstack-11.4: + description: "declared field errors must be a required errors[] array with pointer/message (guide 11.4, [M+R], proxy)." + message: "[11.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#114-field-level-errors-array + severity: warn + formats: [oas3_1] + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"].schema' + then: + function: s11-fieldErrors + + # 11.2 [M+R] — literal problem type values supplied in examples, consts or + # enums use the canonical GovStack URI. Runtime instances are validated by + # the shared schema and conformance tests, not Spectral. + govstack-11.2-type: + description: "literal problem types must use the canonical GovStack problem URI (guide 11.2, [M+R], proxy)." + message: "[11.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/11-errors.md#112-stable-http-problem-type-uri + severity: warn + formats: [oas3_1] + given: '$.paths[*][get,put,post,delete,options,head,patch,trace].responses[?(@property.match(/^[45]/))].content["application/problem+json"]' + then: + function: s11-problemType diff --git a/api-design-guide/linter/rulesets/s12.yaml b/api-design-guide/linter/rulesets/s12.yaml new file mode 100644 index 0000000..42159c3 --- /dev/null +++ b/api-design-guide/linter/rulesets/s12.yaml @@ -0,0 +1,210 @@ +# Rules for §12 pagination, filtering and sorting — generated from the guide; +# see coverage.yaml. +# +# Implements 12.3, 12.4, 12.6, 12.7 (implemented) and 12.1, 12.2, 12.8, 12.9 +# (partial-proxy). 12.5 ("total MAY be omitted") is a permissive MAY and 12.10 +# is an informative out-of-scope statement; neither is in this fragment, per +# coverage.yaml. +# +# Shared heuristic used throughout: a "collection" (list) GET operation is one +# whose path does NOT end in a `{param}` segment, e.g. `/v1/foos` (collection) +# vs. `/v1/foos/{id}` (single item). This is itself a proxy for "this endpoint +# returns a collection" — flagged explicitly wherever it drives a +# partial-proxy rule; where it drives an "implemented" rule (12.3/12.4/12.6/ +# 12.7), it is used only to scope an otherwise-exact shape check to the +# endpoints the shape applies to. +# +# Two carve-outs are shared by every collection-GET rule and therefore live in +# one place, inside `s12-collectionPagination`: the guide 5.10 standard +# unversioned endpoints are not collections, and a collection whose 200 +# response declares its own `maxItems` bound may go unpaginated (guide 12.1). +functionsDir: "../functions" +functions: + - s12-collectionPagination + - s12-sortParam + - envelopeShape +rules: + # 12.1 [M+R] — PROXY (bucket B, MUST -> warn). "Collections must paginate." + # Identifies list endpoints via the path heuristic above and only checks + # that SOME page-size-like query parameter (`pageSize` or `offset`) is + # declared. Does NOT verify the response body is actually bounded/paginated + # in shape (see 12.3/12.6 for the envelope shape checks) or that every + # true collection endpoint matches the path heuristic. + govstack-12.1: + description: "collection GET endpoints must declare a pagination parameter (guide 12.1, [M+R], proxy)." + message: "[12.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#121-collections-must-paginate + severity: warn + formats: [oas3_1] + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]' + then: + function: s12-collectionPagination + functionOptions: + mode: pageParam + + # 12.2 [M+R] — PROXY (bucket B, MUST -> warn). Default pagination MUST be + # cursor-based with `pageSize` + `cursor` query parameters. No-op (via + # `s12-collectionPagination`, mode cursorParams) on operations that declare + # an `offset` parameter, since those opted into the §12.6 alternative. Does + # NOT verify the cursor is opaque/server-encoded (a runtime property) or + # that clients don't parse it. The /health and /ready operational endpoints + # (guide 5.9) are exempted inside `s12-collectionPagination` (exact path-key). + govstack-12.2: + description: "collection GET endpoints must declare pageSize + cursor query parameters by default (guide 12.2, [M+R], proxy)." + message: "[12.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#122-cursor-pagination-by-default + severity: warn + formats: [oas3_1] + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]' + then: + function: s12-collectionPagination + functionOptions: + mode: cursorParams + + # 12.3 [M] — cursor pagination envelope MUST be { items, pageInfo: + # { nextCursor } } with an explicitly nullable, non-empty cursor and no + # declared hasMore field. total is optional per + # 12.5 and its BB-specific semantics remain a review concern. + # No-op on operations that declared an `offset` parameter: those use the + # distinct §12.6 flat envelope instead. The /health and /ready operational + # endpoints (guide 5.9) are exempted inside `s12-collectionPagination`. + govstack-12.3: + description: "collection GET 200 response must use {items, pageInfo{nextCursor}}, with nullable non-empty nextCursor and no hasMore (guide 12.3, [M])." + message: "[12.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#123-cursor-pagination-envelope + severity: error + formats: [oas3_1] + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]' + then: + function: s12-collectionPagination + functionOptions: + mode: cursorEnvelope + + # 12.4 [M+R] — pageSize MUST have a documented default and maximum. Fires + # both when pageSize is missing entirely and when it is present without + # both keywords (both cases mean "not documented"). + govstack-12.4: + description: "pageSize parameter must declare a default and a maximum (guide 12.4, [M+R])." + message: "[12.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#124-documented-pagesize-bounds + severity: error + formats: [oas3_1] + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]' + then: + function: s12-collectionPagination + functionOptions: + mode: pageSizeBounds + + # 12.6 [M+R] — offset pagination, when opted into (an `offset` parameter is + # declared), MUST use the flat envelope { items, offset, limit, total }, + # with `total` required (unlike the optional `total` in 12.3). No-op on + # operations without an `offset` parameter. + govstack-12.6: + description: "collection GET 200 response must use the flat envelope {items, offset, limit, total} when offset pagination is used (guide 12.6, [M+R])." + message: "[12.6][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#126-offset-pagination-envelope + severity: error + formats: [oas3_1] + given: '$.paths[?(!@property.match(/\/\{[^}]+\}$/))][get]' + then: + function: s12-collectionPagination + functionOptions: + mode: offsetEnvelope + + # 12.7 [M] — ADVISORY PROXY: the sort parameter SHOULD be named `sort` + # (rather than `orderBy`/`sortBy`) and use comma-separated `field`/`-field` + # values. Split into a naming check and a shape check. Neither check proves + # that allowed fields, default order, or a stable tie-breaker are documented. + govstack-12.7-name: + description: "sort parameter should use the GovStack default name 'sort' (guide 12.7, [M])." + message: "[12.7][M] parameter name should use the GovStack default \"sort\" (guide 12.7)" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,options,head,patch,trace]" + then: + function: schema + functionOptions: + schema: + type: object + properties: + parameters: + type: array + items: + type: object + properties: + name: + not: + enum: [orderBy, sortBy] + + govstack-12.7-grammar: + description: "sort parameter should use the GovStack field/-field comma-separated grammar (guide 12.7, [M])." + message: "[12.7][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,options,head,patch,trace]" + then: + function: s12-sortParam + + # 12.8 [M+R] — PROXY (bucket B, MUST -> warn). Simple filtering MUST use one + # query parameter per field, equality only. Flags the anti-patterns: + # operator-style bracket suffixes (`field[gte]`, `field[lt]`, ...) and a + # generic catch-all `filter`/`filters` parameter. Does NOT verify that + # non-suspicious parameter names are actually equality-only (a parameter + # named plainly `status` could still carry operator syntax in its value + # grammar; that isn't visible in the schema). + govstack-12.8: + description: "filter parameters must be one-per-field equality, not operator-style or a generic filter param (guide 12.8, [M+R], proxy)." + message: "[12.8][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#128-simple-equality-filtering + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,options,head,patch,trace]" + then: + function: schema + functionOptions: + schema: + type: object + properties: + parameters: + type: array + items: + type: object + properties: + name: + not: + pattern: '(\[(gte|lte|gt|lt|ne|neq|in|nin|like|contains|regex)\]$)|(^(filter|filters)$)' + + # 12.9 [M+R] — PROXY (bucket B, MUST -> warn). Complex filtering MUST use + # POST /v1/{collection}/search, carrying pageSize/cursor in the request + # body and responding with the §12.3 envelope. Identifies the search + # endpoint via a `/search`-suffixed path heuristic (proxy for "this is the + # §6.6/§12.9 complex-filtering endpoint") and does NOT verify that the path + # actually sits under a collection resource, nor the filtering semantics of + # the request body itself. + govstack-12.9-body: + description: "POST .../search request body must carry pageSize and cursor (guide 12.9, [M+R], proxy)." + message: "[12.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#129-complex-filtering-via-search + severity: warn + formats: [oas3_1] + given: '$.paths[?(@property.match(/\/search$/))].post.requestBody.content["application/json"].schema' + then: + function: envelopeShape + functionOptions: + properties: + pageSize: {} + cursor: {} + + govstack-12.9-response: + description: "POST .../search response must use the §12.3 cursor envelope (guide 12.9, [M+R], proxy)." + message: "[12.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-c/12-pagination-filtering-sorting.md#129-complex-filtering-via-search + severity: warn + formats: [oas3_1] + given: '$.paths[?(@property.match(/\/search$/))].post' + then: + function: s12-collectionPagination + functionOptions: + mode: cursorEnvelope diff --git a/api-design-guide/linter/rulesets/s13.yaml b/api-design-guide/linter/rulesets/s13.yaml new file mode 100644 index 0000000..2c0cbf7 --- /dev/null +++ b/api-design-guide/linter/rulesets/s13.yaml @@ -0,0 +1,155 @@ +# Rules for §13 authentication and authorisation — generated from the guide. +# +# Implements 13.1, 13.5 (implemented) and 13.2, 13.3, 13.4, 13.6 (partial-proxy). +# Source text: ../../rules.yaml. Severity/formats per ../README.md#rule-naming-and-severities. +# +# Surface note: §13 rules are "Universal", but the mechanical checks here target +# the OpenAPI surface (formats [oas3_1]). AsyncAPI security coverage/schemes are +# enforced by §17 (17.10 and related); the AsyncAPI parts of §13 are not +# re-checked here. +functionsDir: "../functions" +functions: + - securityCoverage + - s13-schemeExists + - s13-scopeNames + - s13-apiKeyScope +rules: + # 13.1 [M] — declare a security scheme block and apply it by default to every + # operation; per-operation overrides must be explicit. On OpenAPI: root-level + # `security` + `components.securitySchemes`, covering every operation (an + # operation-level `security` — including `security: []` — is an explicit + # override). AsyncAPI coverage is handled by §17.10. + govstack-13.1: + description: "every operation must be covered by a declared security scheme (guide 13.1, [M])." + message: "[13.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation + severity: error + formats: [oas3_1] + given: $ + then: + function: securityCoverage + functionOptions: + requireSchemes: true + + # 13.2 [M+R] — PROXY (bucket B), MUST notched to warn. + # Verifies: an openIdConnect or oauth2 scheme is declared, or the resource- + # server alternative the guide allows (type: http, scheme: bearer, + # bearerFormat: JWT) for an API that validates tokens from an authorization + # server it does not own. + # Does NOT verify: which operations are citizen-facing, that citizen-facing + # operations actually apply this scheme, discovery-URL validity, that the + # resource-server case really is one, or that its accepted issuers and + # required audience are documented (guide 13.2 requires it in prose or an + # adjacent /.well-known/ document; neither is mechanically decidable). + govstack-13.2: + description: "an OAuth 2.0 / OpenID Connect (or resource-server bearer JWT) security scheme must be declared (guide 13.2, [M+R], proxy)." + message: "[13.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations + severity: warn + formats: [oas3_1] + given: $ + then: + function: s13-schemeExists + functionOptions: + types: [openIdConnect, oauth2] + httpBearerFormats: [JWT] + label: "OAuth 2.0 / OpenID Connect (or resource-server bearer JWT)" + + # 13.2 [M+R] — deterministic security baseline: resource-owner password and + # implicit grants MUST NOT be declared. + govstack-13.2-forbidden-flows: + description: "OAuth2 schemes must not declare password or implicit flows (guide 13.2, [M+R])." + message: "[13.2][M+R] OAuth2 password and implicit flows are forbidden." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations + severity: error + formats: [oas3_1] + given: $.components.securitySchemes[*].flows + then: + function: schema + functionOptions: + schema: + type: object + not: + anyOf: + - required: [password] + - required: [implicit] + + # 13.3 [M+R] — PROXY (bucket B), MUST notched to warn. + # Verifies: a service-to-service scheme is declared (mutualTLS, or oauth2 with + # a clientCredentials flow). + # Does NOT verify: which operations cross a BB-to-BB trust boundary, that they + # apply this scheme, or the AsyncAPI X509/SASL variants (that surface is §17). + govstack-13.3: + description: "a service-to-service (mTLS / OAuth client-credentials) scheme must be declared (guide 13.3, [M+R], proxy)." + message: "[13.3][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#133-distinct-scheme-for-bb-to-bb-calls + severity: warn + formats: [oas3_1] + given: $ + then: + function: s13-schemeExists + functionOptions: + types: [mutualTLS] + oauthFlows: [clientCredentials] + label: "service-to-service (mTLS / OAuth client-credentials)" + + # 13.4 [M] — PROXY (bucket B), MUST notched to warn. + # Verifies: every declared OAuth scope string matches one of the documented + # shapes (default bb:{bb-code}:{resource}:{action}, reverse-DNS, or + # resource.action). + # Does NOT verify: that {bb-code} is the BB's registered code (§9.11), scope + # ecosystem-uniqueness, single-convention consistency, or per-operation + # scope documentation. + govstack-13.4: + description: "OAuth scope strings must follow the ecosystem-wide naming convention (guide 13.4, [M], proxy)." + message: "[13.4][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes + severity: warn + formats: [oas3_1] + given: $.components.securitySchemes[*].flows[*].scopes + then: + function: s13-scopeNames + functionOptions: + label: "the GovStack scope naming convention" + patterns: + - '^bb:[a-z][a-z0-9-]*:[A-Za-z0-9-]+:[A-Za-z0-9-]+$' + - '^global\.govstack\.[a-z][a-z0-9-]*\.[A-Za-z0-9-]+\.[A-Za-z0-9-]+$' + - '^[A-Za-z0-9-]+\.[A-Za-z0-9-]+$' + + # 13.5 [M+R] — credentials MUST NOT be declared in query parameters or + # cookies; the Authorization header is the only declared credential channel. + # Mechanically: an apiKey scheme MUST NOT use `in: query` or `in: cookie` + # (path/fragment credentials cannot be expressed as OpenAPI security schemes). + govstack-13.5: + description: "apiKey security schemes must not use in: query or in: cookie (guide 13.5, [M+R])." + message: "[13.5][M+R] Credentials must not be declared in query parameters or cookies; an apiKey scheme must not set in: query or in: cookie. Use the Authorization header." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#135-authorization-is-the-credential-channel + severity: error + formats: [oas3_1] + given: $.components.securitySchemes[*] + then: + function: schema + functionOptions: + schema: + not: + allOf: + - properties: { type: { const: apiKey } } + required: [type] + - properties: { in: { enum: [query, cookie] } } + required: [in] + + # 13.6 [R] — PROXY (bucket B), MUST NOT notched to warn. + # Verifies: no apiKey scheme is applied (via root or operation `security`) to a + # non-operational (non-health-like) operation. + # Does NOT verify: which operations "read or write personal data" (uses a §5.9 + # operational-path heuristic instead), nor credentials applied outside declared + # security requirements. + govstack-13.6: + description: "apiKey schemes must be applied only to operational (health-like) endpoints (guide 13.6, [R], proxy)." + message: "[13.6][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/13-authentication-and-authorisation.md#136-api-keys-only-for-operational-endpoints + severity: warn + formats: [oas3_1] + given: $ + then: + function: s13-apiKeyScope diff --git a/api-design-guide/linter/rulesets/s14.yaml b/api-design-guide/linter/rulesets/s14.yaml new file mode 100644 index 0000000..07f5f2d --- /dev/null +++ b/api-design-guide/linter/rulesets/s14.yaml @@ -0,0 +1,28 @@ +# Rules for §14 idempotency — generated from the guide. +# +# Implements 14.1 (partial-proxy). 14.2/14.4/14.5 are runtime and 14.3/14.6 are +# human review (see coverage.yaml). Source text: ../../rules.yaml. +functionsDir: "../functions" +functions: + - s14-idempotencyKey +rules: + # 14.1 [M+R] — PROXY (bucket B), MUST notched to warn. + # Verifies: a POST that declares a 201 response (a create) accepts an + # `Idempotency-Key` request header (path- or operation-level). + # Does NOT verify: the other MUST triggers that are not mechanically visible + # (moves value, irreversible request, sends a message, creates a subscription, + # starts a long-running job), the SHOULD tier for other mutating POSTs, the + # MAY tier for read-like POSTs, or the §14.6 naturally-idempotent exemption. + govstack-14.1: + description: "create-POSTs (201) must accept an Idempotency-Key header (guide 14.1, [M+R], proxy)." + message: "[14.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/14-idempotency.md#141-idempotency-key-on-non-idempotent-posts + severity: warn + formats: [oas3_1] + given: $.paths[*] + then: + function: s14-idempotencyKey + functionOptions: + header: Idempotency-Key + status: "201" + method: post diff --git a/api-design-guide/linter/rulesets/s15.yaml b/api-design-guide/linter/rulesets/s15.yaml new file mode 100644 index 0000000..a1db609 --- /dev/null +++ b/api-design-guide/linter/rulesets/s15.yaml @@ -0,0 +1,59 @@ +# Rules for §15 asynchronous operations — generated from the guide. +# +# Implements 15.1 and 15.5, plus the 15.4 polling-path proxy. Operation +# resource fields and lifecycle states are owned and documented by each BB. +# Source text: ../../rules.yaml. Severity/formats per ../README.md#rule-naming-and-severities. +functionsDir: "../functions" +functions: + - responseHeaderRequired + - s15-operationsPolling + - s15-cancelPath +rules: + # 15.1 [M+R] — operations that cannot complete synchronously MUST return 202 + # with a Location header. Mechanically: every 202 response declares a Location + # header. That the Location "points to an Operation resource" is a runtime + # value and is not verified here. + govstack-15.1: + description: "every 202 response must declare a Location header (guide 15.1, [M+R])." + message: "[15.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#151-202-with-operation-location + severity: error + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch].responses" + then: + function: responseHeaderRequired + functionOptions: + status: "202" + headers: [Location] + + # 15.4 [M+R] — PROXY (bucket B), notched to warn. + # Verifies: when the Operations pattern is used (an Operation schema or an + # /operations/ path exists), a canonical GET /v{N}/operations/{operationId} + # poll endpoint is declared. + # Does NOT verify: whether asynchronous behaviour is actually needed, or the + # response shape or lifecycle semantics of the poll endpoint, which are + # documented locally by the BB. + govstack-15.4: + description: "a canonical GET /v{N}/operations/{operationId} poll endpoint must exist when Operations are used (guide 15.4, [M+R], proxy)." + message: "[15.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#154-polling-the-operation-resource + severity: warn + formats: [oas3_1] + given: $ + then: + function: s15-operationsPolling + + # 15.5 [M+R] — cancellation, when supported, MUST be + # POST /v{N}/operations/{operationId}/cancel. Any Operation-cancellation path + # (a `.../operations/.../cancel` path) that is not the canonical shape, or that + # does not declare a POST, is flagged. Domain-level cancels not under + # /operations/ are out of scope. + govstack-15.5: + description: "Operation cancellation should use POST /v{N}/operations/{operationId}/cancel (guide 15.5, [M+R])." + message: "[15.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/15-asynchronous-operations.md#155-cancellation-via-cancel-sub-resource + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: s15-cancelPath diff --git a/api-design-guide/linter/rulesets/s16.yaml b/api-design-guide/linter/rulesets/s16.yaml new file mode 100644 index 0000000..fab225b --- /dev/null +++ b/api-design-guide/linter/rulesets/s16.yaml @@ -0,0 +1,158 @@ +# Rules for §16 CloudEvents and webhooks (OpenAPI/webhooks surface). +# +# Implements guide rules 16.1, 16.2, 16.3, 16.4, 16.11. Source text: +# ../../rules.yaml. Severity policy and formats follow +# ../README.md#rule-naming-and-severities. +# +# Scope note: §16 spans both the OpenAPI/webhooks and the AsyncAPI surfaces. +# These rules target the OpenAPI document (formats: [oas3_1]); the AsyncAPI +# clause of 16.1 (brokered transports use AsyncAPI 3.0) is not enforced here. +functionsDir: "../functions" +functions: + - envelopeShape + - mediaTypeExpected + - s16-eventField + - s16-subscriptionEndpoints +rules: + # 16.1 [M+R] — PROXY (bucket B), notched MUST -> warn. + # Verifies: HTTP push events are NOT modelled with operation `callbacks`; the + # GovStack push mechanism is OpenAPI 3.1 top-level `webhooks`. + # Does NOT verify: that a BB with events documents them at all (an absent + # event surface is not detectable), nor the AsyncAPI-3.0 clause for brokered + # transports (MQTT/AMQP/Kafka/WebSockets/SSE). + govstack-16.1: + description: "HTTP push events must use top-level webhooks, not operation callbacks (guide 16.1, [M+R], proxy)." + message: "[16.1][M+R] HTTP push events MUST use OpenAPI 3.1 top-level `webhooks`; operation `callbacks` are not the GovStack push mechanism." + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#161-event-surfaces-documented + severity: warn + formats: [oas3_1] + given: $.paths[*][get,put,post,delete,patch].callbacks + then: + function: undefined + + # 16.2 [M] — the CloudEvents envelope MUST declare specversion (const "1.0"), + # id, source, type and data (GovStack domain payload lives under `data`). + govstack-16.2: + description: "webhook event payload must be a CloudEvents v1.0 envelope: specversion(const \"1.0\")/id/source/type/data (guide 16.2, [M])." + message: "[16.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required + severity: error + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content[*].schema + then: + function: envelopeShape + functionOptions: + requiredProperties: [specversion, id, source, type, data] + properties: + specversion: { const: "1.0" } + + # 16.2 [M] SHOULD sentence — split per the mixed-strength convention: `time` + # and `datacontenttype` SHOULD be included (SHOULD -> warn). + govstack-16.2-recommended: + description: "CloudEvents envelope should include time and datacontenttype (guide 16.2, [M], SHOULD)." + message: "[16.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required + severity: warn + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content[*].schema + then: + function: envelopeShape + functionOptions: + properties: + time: {} + datacontenttype: {} + + # 16.2 [M] structured-content-mode sentence — split per the mixed-strength + # convention. On the webhooks surface the event MUST be delivered as + # `application/cloudevents+json`; a plain `application/json` body is the + # CloudEvents binary content mode, whose body carries only `data` and so + # is not the structured event envelope required by the guide. + govstack-16.2-structured: + description: "webhook events must use CloudEvents structured content mode, not binary (guide 16.2, [M])." + message: "[16.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required + severity: error + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content + then: + function: mediaTypeExpected + functionOptions: + require: '^application/cloudevents\+json\s*(;|$)' + forbid: '^application/json\s*(;|$)' + + # 16.3 [M] — ADVISORY PROXY: the pinned event `type` value SHOULD follow the + # reverse-DNS global.govstack.{bb-code}.{resource}.{action} default. Registry + # membership and global stability need review. Only pinned const/enum values + # are checked (a free-form `type` is a 16.2 presence concern). + govstack-16.3: + description: "event type should use the GovStack reverse-DNS default (guide 16.3, [M], advisory proxy)." + message: "[16.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types + severity: warn + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content[*].schema + then: + function: s16-eventField + functionOptions: + property: type + name: event type + expected: "reverse-DNS global.govstack.{bb-code}.{resource}.{action}" + match: '^global\.govstack\.[a-z][a-z0-9-]{1,30}(?:\.[a-z][a-zA-Z0-9]*){2,}$' + + # 16.3 [M] — a pinned event `type` MUST NOT carry a major-version segment. + # This deterministic invariant remains an error independently of the + # advisory reverse-DNS default above. + govstack-16.3-no-version: + description: "event type must not contain a major-version segment (guide 16.3, [M])." + message: "[16.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types + severity: error + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content[*].schema + then: + function: s16-eventField + functionOptions: + property: type + name: event type + expected: "no major-version segment" + forbidSegments: '^v[0-9]+$' + + # 16.4 [M+R] — PROXY (bucket B), notched MUST/MUST NOT -> warn. + # Verifies: the pinned CloudEvents `source` value contains no deployment-host / + # environment / pod / broker / queue token (localhost, IPs, ports, .internal, + # .svc, .cluster.local, dev/test/staging/prod/... segments). + # Does NOT verify: that the source positively identifies the publishing BB in + # a "stable" way — only known-unstable tokens are flagged. + govstack-16.4: + description: "CloudEvents source must be stable, not a deployment host/env/pod/broker/queue (guide 16.4, [M+R], proxy)." + message: "[16.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#164-stable-cloudevents-source + severity: warn + formats: [oas3_1] + given: $.webhooks[*][post,put,patch].requestBody.content[*].schema + then: + function: s16-eventField + functionOptions: + property: source + name: event source + sources: [const, enum, default, example, examples] + flags: i + expected: "it must not identify a specific deployment host, pod, broker, queue, or environment" + forbidPattern: '(?:^|[^a-z0-9])(?:dev|test|staging|stage|qa|uat|prod|production|preprod|sandbox|pod|broker|queue)(?:[^a-z0-9]|$)|localhost|\.svc(?:[./]|$)|\.cluster\.local|\.internal(?:[:/]|$)|(?:\d{1,3}\.){3}\d{1,3}|:\d{2,5}(?:/|$)' + + # 16.11 [M+R] — PROXY (bucket B), notched MUST -> warn. + # Verifies: when a subscription surface exists (paths named /subscriptions), + # it exposes create/list/delete interfaces. Conditional signing-key rotation + # is not checked because signing is optional and profile-specific. + # Does NOT verify: that a BB which should offer subscriptions actually does + # (an absent subscription surface is not flagged), nor the AsyncAPI + # message-command control plane the guide also permits. + govstack-16.11: + description: "subscription management must expose create/list/delete (guide 16.11, [M+R], proxy)." + message: "[16.11][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/16-cloudevents-and-webhooks.md#1611-subscription-management-interfaces + severity: warn + formats: [oas3_1] + given: $.paths + then: + function: s16-subscriptionEndpoints diff --git a/api-design-guide/linter/rulesets/s17-strict.yaml b/api-design-guide/linter/rulesets/s17-strict.yaml new file mode 100644 index 0000000..fdc610b --- /dev/null +++ b/api-design-guide/linter/rulesets/s17-strict.yaml @@ -0,0 +1,28 @@ +# STRICT-only rules for §17 — ships only via ../strict.yaml (opt-in), at warn. +# +# Source text: ../../rules.yaml (§17.3). See ../README.md#rule-naming-and-severities. +functionsDir: "../functions" +functions: + - valuePattern +rules: + # 17.3 [R] STRICT-ONLY (warn). Heuristic scan of channel addresses and channel + # parameter names for personal-data / identifier tokens. This is a keyword + # proxy: it cannot prove a token carries personal data, and deliberately omits + # broad words (bare "name"/"address") except as bounded segments to limit + # false positives. Topic/queue/routing-key names not expressed in the AsyncAPI + # document are out of reach. + govstack-17.3: + description: "Channel addresses/parameter names must not contain personal-data tokens (guide 17.3, [R], strict)." + message: "[17.3][R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#173-no-personal-data-in-channels + severity: warn + formats: [aas3] + given: + - $.channels[*].address + - $.channels[*].parameters.*~ + then: + function: valuePattern + functionOptions: + name: channel address/parameter + flags: i + forbidPattern: 'phone|msisdn|email|e-mail|passport|national-?id|first-?name|last-?name|sur-?name|given-?name|full-?name|maiden-?name|date-?of-?birth|birth-?date|(^|[.\-_])(ssn|dob|name)([.\-_]|$)' diff --git a/api-design-guide/linter/rulesets/s17.yaml b/api-design-guide/linter/rulesets/s17.yaml new file mode 100644 index 0000000..0fab47b --- /dev/null +++ b/api-design-guide/linter/rulesets/s17.yaml @@ -0,0 +1,223 @@ +# Rules for §17 event-driven APIs (AsyncAPI channel documentation). +# +# Source text: ../../rules.yaml (§17). Severity policy, formats (aas3) and the +# proxy/notch conventions follow ../README.md#rule-naming-and-severities and +# ../coverage.yaml. AsyncAPI-surface rules gate on `formats: [aas3]`. +# +# Custom functions live in ../functions; every function referenced below is +# listed so the bundler and the CLI resolve them through the extends chain. +functionsDir: "../functions" +functions: + - valuePattern + - s17-channelIds + - schemaPropertyNames + - s17-channelParameters + - s17-cloudEventsPayload + - s17-securityCoverage + - s17-rejectionMessage + - s17-requestReply + - s17-protocolBindings +rules: + # 17.1 [M+R] — every operation MUST declare action send|receive (the BB's + # publish/consume perspective). + govstack-17.1: + description: "Every AsyncAPI operation must declare action send or receive (guide 17.1, [M+R])." + message: "[17.1][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#171-send-and-receive-perspective + severity: error + formats: [aas3] + given: $.operations[*] + then: + function: schema + functionOptions: + schema: + type: object + required: [action] + properties: + action: + enum: [send, receive] + + # 17.2 [M+R] — logical channel IDs (the keys under `channels`) MUST follow + # reverse-DNS global.govstack.{bb-code}.v{major}.{resource}.{event}. Channel + # Object addresses intentionally retain protocol-native destination syntax. + govstack-17.2: + description: "Logical channel IDs should follow the GovStack reverse-DNS grammar (guide 17.2, [M+R], proxy)." + message: "[17.2][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses + severity: warn + formats: [aas3] + given: $.channels + then: + function: s17-channelIds + + # 17.4 [M+R] — every {param} in a channel address MUST be declared under the + # channel parameters object and documented (non-empty description). The rule's + # enum-for-closed-sets and examples-for-open-values clauses are [R]: whether a + # parameter's value set is closed cannot be determined from the document. + govstack-17.4: + description: "Every {param} in a channel address must be declared under parameters and documented (guide 17.4, [M+R])." + message: "[17.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#174-declared-channel-parameters + severity: error + formats: [aas3] + given: $.channels[*] + then: + function: s17-channelParameters + + # 17.5 [M+R] — PROXY (bucket B). SHOULD NOT -> info. Flags env/broker tokens + # (dev/test/prod/staging/kafka/mqtt/amqp/…) as address segments. Does NOT + # verify arbitrary broker implementation names or deployment prefixes, and a + # token list can miss/over-match legitimate resource names. + govstack-17.5: + description: "Channel addresses should not contain environment/broker names (guide 17.5, [M+R], proxy)." + message: "[17.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#175-no-environment-names-in-addresses + severity: info + formats: [aas3] + given: $.channels[*].address + then: + function: valuePattern + functionOptions: + name: channel address + flags: i + forbidPattern: '(^|[.\-_])(dev|test|prod|staging|uat|qa|sandbox|preprod|nonprod|kafka|rabbitmq|mqtt|amqp|broker)([.\-_]|$)' + + # 17.6 [M] — domain-event Message Objects MUST use the structured CloudEvents + # JSON media type and carry any GovStack domain data under `data`. The rule checks + # messages that declare the media type, envelope reference, or CloudEvents + # fields. It deliberately skips other messages because their semantic role is + # not machine-identifiable. The base validator resolves external references. + govstack-17.6: + description: "Domain-event Message Objects must use application/cloudevents+json and structured CloudEvents payloads with any domain data under `data` (guide 17.6, [M])." + message: "[17.6][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads + severity: error + formats: [aas3] + given: $.components.messages[*] + then: + function: s17-cloudEventsPayload + + # 17.7 [M] — Message Objects remain local. Recognizable CloudEvents and async + # error payloads MUST reference the reviewed envelope or async-error schema + # from the vendored common file. Other message kinds are outside this rule. + # The base validator separately proves that the external ref resolves. + govstack-17.7: + description: "CloudEvents and async-error Message Objects must reference payload schemas from the vendored govstack-asyncapi-common.yaml file (guide 17.7, [M])." + message: "[17.7][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#177-shared-cloudevents-envelope-schema + severity: error + formats: [aas3] + resolved: false + given: $.components.messages[*] + then: + function: s17-cloudEventsPayload + functionOptions: + requireSharedReference: true + + # 17.8 [M+R] — PROXY (bucket B), MUST -> warn. GovStack-owned message headers + # MUST be camelCase and MUST NOT use the X- prefix (camelCase subsumes the X- + # ban). Does NOT verify the command idempotency-key requirement (needs command + # identification) nor CloudEvents trace extension attributes. + govstack-17.8: + description: "Message header names must be camelCase / not X- prefixed (guide 17.8, [M+R], proxy)." + message: "[17.8][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#178-message-headers-and-idempotency-metadata + severity: warn + formats: [aas3] + given: $.components.messages[*].headers + then: + function: schemaPropertyNames + functionOptions: + casing: camel + + # 17.9 [M+R] — PROXY (bucket B), MUST -> warn. Localisation headers MUST be + # exactly acceptLanguage / contentLanguage. Flags near-miss variants + # (Accept-Language, content_language, language, locale, lang, …). Does NOT + # verify inbound-vs-outbound direction, nor the "stable fields MUST NOT be + # translated" clause. + govstack-17.9: + description: "Localisation message headers must be named acceptLanguage/contentLanguage (guide 17.9, [M+R], proxy)." + message: "[17.9][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#179-message-localisation-headers + severity: warn + formats: [aas3] + given: $.components.messages[*].headers + then: + function: schemaPropertyNames + functionOptions: + forbidPattern: '^(?:[Aa]ccept[-_ ][Ll]anguage|ACCEPT[-_ ]LANGUAGE|[Cc]ontent[-_ ][Ll]anguage|CONTENT[-_ ]LANGUAGE|language|Language|LANGUAGE|locale|Locale|lang|Lang)$' + + # 17.10 [M+R] — security schemes MUST be declared under components and applied + # on servers/operations so every operation is covered. Message signing (§16.5) + # is not accepted as a substitute (only servers/operations security counts). + govstack-17.10: + description: "AsyncAPI security schemes must cover every operation via servers/operations (guide 17.10, [M+R])." + message: "[17.10][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1710-security-schemes-cover-every-operation + severity: error + formats: [aas3] + given: $ + then: + function: s17-securityCoverage + + # 17.16 [M+R] — PROXY (bucket B), MUST -> warn. Asserts an async rejection/ + # failure message using the §11 error envelope exists and is correlated. Does + # NOT identify which operations are command-like, nor verify the correlation + # actually points at the initiating message. + govstack-17.16: + description: "An async rejection message using the §11 error envelope, correlated, must exist (guide 17.16, [M+R], proxy)." + message: "[17.16][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1716-async-rejection-error-messages + severity: warn + formats: [aas3] + given: $ + then: + function: s17-rejectionMessage + + # 17.17 [M+R] — when an operation declares a reply, it MUST declare the reply + # channel/address AND a correlation mechanism (a message correlationId or a + # reply address location). Fire-and-forget "pretending" is not decidable. + govstack-17.17: + description: "Request-reply operations must declare a reply channel/address and a correlation mechanism (guide 17.17, [M+R])." + message: "[17.17][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1717-declared-request-reply-correlation + severity: error + formats: [aas3] + given: $.operations[*] + then: + function: s17-requestReply + + # 17.19 [M+R] — PROXY (bucket B), MUST -> warn. Where servers declare a + # binding-relevant protocol, each channel (or an operation bound to it) should + # declare a matching protocol binding. Does NOT verify the specific binding + # fields (topic/key, QoS/retained, exchange/routing-key, framing). + govstack-17.19: + description: "Channels/operations should declare protocol bindings matching the server protocol (guide 17.19, [M+R], proxy)." + message: "[17.19][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1719-protocol-bindings-where-relevant + severity: warn + formats: [aas3] + given: $ + then: + function: s17-protocolBindings + + # 17.20 [M+R] — every components.messages entry MUST define at least one + # example. The SHOULD to show headers+payload per message family + # (command/event/error/completion) is not mechanically verified. + govstack-17.20: + description: "Messages should define representative examples (guide 17.20, [M+R])." + message: "[17.20][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/17-asyncapi-channel-rules.md#1720-representative-message-examples + severity: warn + formats: [aas3] + given: $.components.messages[*] + then: + function: schema + functionOptions: + schema: + type: object + required: [examples] + properties: + examples: + type: array + minItems: 1 diff --git a/api-design-guide/linter/rulesets/s18.yaml b/api-design-guide/linter/rulesets/s18.yaml new file mode 100644 index 0000000..c08fcac --- /dev/null +++ b/api-design-guide/linter/rulesets/s18.yaml @@ -0,0 +1,135 @@ +# Rules for §18 compatibility and lifecycle — generated from the guide; see coverage.yaml. +# +# Implements guide rules 18.1, 18.2, 18.5, 18.7. Source text: ../../rules.yaml. +# Severity policy and formats follow ../README.md#rule-naming-and-severities. +# 18.3/18.4 (backward-/breaking-change diffing against a previous version) and +# 18.6 (informative) are out of scope for this fragment. +# +# Custom functions: s18-versionMajorConsistency reads info.version alongside +# paths/channels in one pass (18.2); no shared function combines those two +# reads. s18-deprecatedHeaders (18.5) walks operations directly instead of +# relying on a `given` JSONPath filter chained after a bracketed method list +# ($.paths[*][get,put,...][?(@.deprecated == true)]) — that chained-filter +# form does not select reliably against this repo's installed Spectral/nimma +# version (confirmed by inspecting nimma's compiled matcher; the filter +# silently matches nothing regardless of the predicate). valuePattern / +# extensionShape are shared. +functionsDir: "../functions" +functions: + - valuePattern + - extensionShape + - s18-versionMajorConsistency + - s18-deprecatedHeaders +rules: + # 18.1 [M] — info.version MUST follow SemVer. Surface: Universal, so this + # checks both OpenAPI and AsyncAPI documents. formats widened the same way + # as the 2.5/3.5 info rules so a wrong-spec-version doc still gets told its + # info.version is non-SemVer. + govstack-18.1: + description: "info.version must be a SemVer string (guide 18.1, [M])." + message: "[18.1][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#181-semver-versioning + severity: error + formats: [oas3, aas2, aas3] + given: $.info.version + then: + function: valuePattern + functionOptions: + name: info.version + match: '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$' + + # 18.2 [M] OpenAPI half — a path's /v{N}/ prefix (when present) MUST match + # info.version's major segment. Paths lacking a version prefix entirely are + # guide 5.1's concern, not this rule's. + govstack-18.2-openapi: + description: "OpenAPI path version prefixes should match info.version's major segment (guide 18.2, [M], default convention)." + message: "[18.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel + severity: warn + formats: [oas3_1] + given: $ + then: + function: s18-versionMajorConsistency + functionOptions: + surface: openapi + + # 18.2 [M] AsyncAPI half — a logical channel ID MUST include the major version, + # consistent with info.version's major segment. Does NOT verify the text's + # alternative "equivalent machine-readable version field documented in + # govstack-asyncapi-common.yaml" (needs the vendored common file, out of + # scope here); only the logical-ID convention is checked. + govstack-18.2-asyncapi: + description: "AsyncAPI logical channel IDs should include a major version matching info.version (guide 18.2, [M], default convention)." + message: "[18.2][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel + severity: warn + formats: [aas3] + given: $ + then: + function: s18-versionMajorConsistency + functionOptions: + surface: asyncapi + + # 18.5 [M+R] — deprecated OpenAPI operations MUST return a Deprecation + # header (RFC 9745) and a Sunset header (RFC 8594). Checked on 2xx + # responses (the primary success contract); the RFC 9745/8594 structured- + # field VALUE syntax is not checkable from a static header declaration, only + # the headers' presence is. Given is the whole paths object rather than a + # filtered operation list — see the functions: comment above for why. + govstack-18.5: + description: "deprecated operations must declare Deprecation and Sunset response headers (guide 18.5, [M+R])." + message: "[18.5][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#185-deprecation-and-sunset-headers + severity: error + formats: [oas3_1] + given: $.paths + then: + function: s18-deprecatedHeaders + + # 18.7 [M+R] — AsyncAPI channels/operations/messages that declare + # x-govstack-deprecated MUST shape it as {since, sunset, replacement, + # reason}. Detecting "is this deprecated" relies on the extension's own + # presence, since AsyncAPI 3.0 has no native `deprecated` flag; an entity + # deprecated only in prose without the extension cannot be detected this + # way. + govstack-18.7: + description: "x-govstack-deprecated must declare since, sunset, replacement, and reason (guide 18.7, [M+R])." + message: "[18.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata + severity: error + formats: [aas3] + given: + - "$.channels[?(@['x-govstack-deprecated'])]" + - "$.operations[?(@['x-govstack-deprecated'])]" + - "$.components.messages[?(@['x-govstack-deprecated'])]" + then: + function: extensionShape + functionOptions: + extension: x-govstack-deprecated + valueType: object + requiredKeys: [since, sunset, replacement, reason] + + # 18.7 [M+R] — the same deprecated channels/operations/messages MUST also + # note the deprecation in their description. Plain-text presence check for + # a "deprecat*" mention; cannot verify the description is substantively + # useful. + govstack-18.7-description: + description: "deprecated channels/operations/messages must mention the deprecation in their description (guide 18.7, [M+R])." + message: "[18.7][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata + severity: error + formats: [aas3] + given: + - "$.channels[?(@['x-govstack-deprecated'])]" + - "$.operations[?(@['x-govstack-deprecated'])]" + - "$.components.messages[?(@['x-govstack-deprecated'])]" + then: + function: schema + functionOptions: + schema: + type: object + required: [description] + properties: + description: + type: string + pattern: '[Dd]eprecat' diff --git a/api-design-guide/linter/rulesets/s19.yaml b/api-design-guide/linter/rulesets/s19.yaml new file mode 100644 index 0000000..eea7da4 --- /dev/null +++ b/api-design-guide/linter/rulesets/s19.yaml @@ -0,0 +1,50 @@ +# Rules for §19 localisation — generated from the guide; see coverage.yaml. +# +# Implements guide rule 19.4 as a partial-proxy (bucket B). Source text: +# ../../rules.yaml. 19.1-19.3 (honouring request language, not translating +# stable content, default response language) are runtime behaviour, out of +# scope for a static linter. +# +# 19.4's "Responses or messages with localised content MUST include the +# response language header" cannot be checked directly: whether a document +# node carries "localised content" is not decidable from a static spec. The +# proxy inverts the MUST into its checkable half: wherever the localisation +# *input* signal is present (Accept-Language / acceptLanguage), the paired +# *output* header MUST also be present. Severity is notched one step down +# from the rule's MUST per the partial-proxy policy (MUST -> warn). +functionsDir: "../functions" +functions: + - s19-localisationHeaders +rules: + # 19.4 [M+R] OpenAPI half — PROXY. Does NOT verify that a response actually + # carries localised content when Accept-Language is absent; only that, once + # an operation accepts Accept-Language, some response declares + # Content-Language. + govstack-19.4-openapi: + description: "operations accepting Accept-Language must have a response declaring Content-Language (guide 19.4, [M+R], proxy)." + message: "[19.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-e/19-localisation.md#194-declare-the-response-language + severity: warn + formats: [oas3_1] + given: "$.paths[*][get,put,post,delete,patch,options,head,trace]" + then: + function: s19-localisationHeaders + functionOptions: + surface: openapi + + # 19.4 [M+R] AsyncAPI half — PROXY, coarser: pairing is done at the channel + # level (any message on the channel declaring acceptLanguage requires some + # message on the same channel to declare contentLanguage), because guide + # 17.9 puts these two headers on *different* messages (inbound vs outbound) + # by design, so a per-message check would misfire on compliant specs. + govstack-19.4-asyncapi: + description: "channels with an acceptLanguage message must have a message declaring contentLanguage (guide 19.4, [M+R], proxy)." + message: "[19.4][M+R] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-e/19-localisation.md#194-declare-the-response-language + severity: warn + formats: [aas3] + given: $.channels[*] + then: + function: s19-localisationHeaders + functionOptions: + surface: asyncapi diff --git a/api-design-guide/linter/rulesets/s20.yaml b/api-design-guide/linter/rulesets/s20.yaml new file mode 100644 index 0000000..5d6dd12 --- /dev/null +++ b/api-design-guide/linter/rulesets/s20.yaml @@ -0,0 +1,77 @@ +# Rules for §20 conformance and validation — generated from the guide; see coverage.yaml. +# +# Implements guide rule 20.3 (the info.x-govstack-api-guide conformance +# declaration). Source text: ../../rules.yaml. 20.1 (run the OpenAPI/AsyncAPI +# schema validator) and 20.2 (meta/self-referential governance of the ruleset +# itself) are driver/CI and human territory respectively, out of scope here. +functionsDir: "../functions" +functions: + - extensionShape +rules: + # 20.3 [M] — info.x-govstack-api-guide MUST be an object with exact-target + # SemVer `version` and `rulesetVersion`. Exact supported-version matching is + # enforced by the repository driver. Surface: Universal, so this applies to both OpenAPI and + # AsyncAPI canonical files' info block; formats widened like the other + # info-level rules so it still fires on a wrong-spec-version doc. + govstack-20.3: + description: "info.x-govstack-api-guide must declare SemVer version and rulesetVersion (guide 20.3, [M])." + message: "[20.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version + severity: error + formats: [oas3, aas2, aas3] + given: $.info + then: + function: extensionShape + functionOptions: + extension: x-govstack-api-guide + valueType: object + requiredKeys: [version, rulesetVersion] + semverKeys: [version, rulesetVersion] + + # 20.3 [M] — exceptions are reviewable, expiring records. The repository + # driver additionally verifies that `rule` is a known guide rule and that the + # exception is not expired before it suppresses any finding. + govstack-20.3-exceptions: + description: "x-govstack-api-guide.exceptions entries must be objects referencing an approved exception record (guide 20.3, [M])." + message: "[20.3][M] {{error}}" + documentationUrl: https://github.com/GovStackWorkingGroup/bb-template/blob/main/api-design-guide/part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version + severity: error + formats: [oas3, aas2, aas3] + given: $.info + then: + function: schema + functionOptions: + schema: + type: object + properties: + x-govstack-api-guide: + type: object + properties: + exceptions: + type: array + items: + type: object + additionalProperties: false + required: [rule, scope, rationale, record, reviewedBy, reviewedAt, expiresAt] + properties: + rule: + type: string + pattern: '^\d+\.\d+$' + scope: + type: string + pattern: '^(?:/(?:[^~]|~[01])*)*$' + rationale: + type: string + minLength: 10 + record: + type: string + pattern: '^https://' + reviewedBy: + type: string + minLength: 2 + reviewedAt: + type: string + pattern: '^\d{4}-\d{2}-\d{2}$' + expiresAt: + type: string + pattern: '^\d{4}-\d{2}-\d{2}$' diff --git a/api-design-guide/linter/strict.yaml b/api-design-guide/linter/strict.yaml new file mode 100644 index 0000000..04c03ed --- /dev/null +++ b/api-design-guide/linter/strict.yaml @@ -0,0 +1,21 @@ +# GovStack API Design Guide — Spectral ruleset (STRICT profile) +# ============================================================= +# Everything in the normative ruleset.yaml PLUS opt-in "strict" heuristics that +# are deliberately noisy (plural-resource guesses, abbreviation dictionaries, +# personal-data term scans, …). Run this profile for a thorough review; run +# ruleset.yaml for CI gating. +# +# npx spectral lint -r strict.yaml path/to/openapi.yaml +# +# Composition (verified to load via bundler and CLI): strict.yaml extends the +# main ruleset (an extends-of-extends: strict -> ruleset.yaml -> section +# fragments) plus one rulesets/sNN-strict.yaml fragment per section that owns +# STRICT-only rules. +extends: + - ./ruleset.yaml + - ./rulesets/s04-strict.yaml + - ./rulesets/s05-strict.yaml + - ./rulesets/s08-strict.yaml + - ./rulesets/s09-strict.yaml + - ./rulesets/s17-strict.yaml +rules: {} diff --git a/api-design-guide/linter/tests/_lint-helper.mjs b/api-design-guide/linter/tests/_lint-helper.mjs new file mode 100644 index 0000000..c5acc55 --- /dev/null +++ b/api-design-guide/linter/tests/_lint-helper.mjs @@ -0,0 +1,43 @@ +// Shared helpers for the test suite. Not a test file (the leading underscore +// keeps `node --test` from treating it as one). Bundles a YAML ruleset the same +// way the Spectral CLI does — via the ruleset bundler + loader — and lints +// documents with spectral-core, so tests exercise the exact shipping config. + +import pkg from '@stoplight/spectral-core'; +const { Spectral, Document } = pkg; +import { bundleAndLoadRuleset } from '@stoplight/spectral-ruleset-bundler/with-loader'; +import Parsers from '@stoplight/spectral-parsers'; +import { fetch } from '@stoplight/spectral-runtime'; +import * as fs from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +export const LINTER_DIR = resolve(HERE, '..'); +export const STRICT_PATH = resolve(LINTER_DIR, 'strict.yaml'); +export const RULESET_PATH = resolve(LINTER_DIR, 'ruleset.yaml'); + +/** Bundle + load a YAML ruleset file into a spectral-core Ruleset. */ +export async function loadRuleset(rulesetFile) { + return bundleAndLoadRuleset(resolve(rulesetFile), { fs, fetch }); +} + +/** A Spectral instance with the given ruleset (default: the strict bundle). */ +export async function makeSpectral(rulesetFile = STRICT_PATH) { + const ruleset = await loadRuleset(rulesetFile); + const spectral = new Spectral(); + spectral.setRuleset(ruleset); + return spectral; +} + +/** Lint a file on disk. Returns spectral-core findings. */ +export async function lintFile(spectral, filePath) { + const abs = resolve(filePath); + const doc = new Document(fs.readFileSync(abs, 'utf8'), Parsers.Yaml, abs); + return spectral.run(doc); +} + +/** Lint an in-memory YAML string. */ +export async function lintString(spectral, source, uri = 'inline.yaml') { + return spectral.run(new Document(source, Parsers.Yaml, uri)); +} diff --git a/api-design-guide/linter/tests/coverage.test.mjs b/api-design-guide/linter/tests/coverage.test.mjs new file mode 100644 index 0000000..680f5af --- /dev/null +++ b/api-design-guide/linter/tests/coverage.test.mjs @@ -0,0 +1,100 @@ +// Coverage drift checks. Opt-in: only runs under COVERAGE_ENFORCE=1, otherwise +// every check is skipped (with a message). Reconciles three artefacts: +// ../rules.yaml — the generated rule catalogue (source of truth) +// ./coverage.yaml — the coverage contract (rule -> status -> spectral rules) +// ruleset.yaml/strict.yaml bundles — the rules actually shipped +// +// It NEVER edits coverage.yaml; it only asserts they agree. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; +import { loadRuleset, LINTER_DIR, RULESET_PATH, STRICT_PATH } from './_lint-helper.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = join(HERE, 'fixtures'); + +const enforce = process.env.COVERAGE_ENFORCE === '1'; +const opts = enforce ? {} : { skip: 'set COVERAGE_ENFORCE=1 to run coverage drift checks' }; + +const IMPLEMENTED = new Set(['implemented', 'partial-proxy']); + +// ---- load artefacts (guarded so the skipped case never throws at import) ---- +function loadCatalogue() { + const doc = YAML.parse(readFileSync(resolve(LINTER_DIR, '..', 'rules.yaml'), 'utf8')); + return doc.rules || []; +} +function loadCoverage() { + const doc = YAML.parse(readFileSync(join(LINTER_DIR, 'coverage.yaml'), 'utf8')); + return doc.rules || []; +} +function unionSpectralRules(coverage, statusFilter) { + const set = new Set(); + for (const e of coverage) { + if (statusFilter(e.status)) for (const r of e.spectral_rules || []) set.add(r); + } + return set; +} +function diff(a, b) { + return [...a].filter((x) => !b.has(x)); +} +function assertSameSet(actual, expected, label) { + const missing = diff(expected, actual); // in contract, not shipped + const extra = diff(actual, expected); // shipped, not in contract + assert.ok( + missing.length === 0 && extra.length === 0, + `${label}\n in contract but not shipped: ${JSON.stringify(missing)}\n` + + ` shipped but not in contract: ${JSON.stringify(extra)}`, + ); +} + +test('(a) rules.yaml and coverage.yaml list exactly the same rule ids, once each', opts, () => { + const catalogue = loadCatalogue(); + const catalogueIds = catalogue.map((rule) => String(rule.id)); + const coverage = loadCoverage(); + const covIds = coverage.map((e) => String(e.id)); + + const dupes = covIds.filter((id, i) => covIds.indexOf(id) !== i); + assert.equal(dupes.length, 0, `coverage.yaml has duplicate ids: ${JSON.stringify([...new Set(dupes)])}`); + + assertSameSet(new Set(covIds), new Set(catalogueIds), 'rule id mismatch between rules.yaml and coverage.yaml:'); + + const coverageById = new Map(coverage.map((entry) => [String(entry.id), entry])); + for (const rule of catalogue) { + assert.equal( + coverageById.get(String(rule.id))?.class, + rule.class, + `coverage class differs from rules.yaml for ${rule.id}`, + ); + } +}); + +test('(b) implemented/partial-proxy spectral_rules == rules shipped in ruleset.yaml', opts, async () => { + const coverage = loadCoverage(); + const contract = unionSpectralRules(coverage, (s) => IMPLEMENTED.has(s)); + const bundle = new Set(Object.keys((await loadRuleset(RULESET_PATH)).rules)); + assertSameSet(bundle, contract, 'ruleset.yaml bundle vs coverage implemented/partial-proxy:'); +}); + +test('(c) strict-only spectral_rules == the extra rules strict.yaml adds', opts, async () => { + const coverage = loadCoverage(); + const contract = unionSpectralRules(coverage, (s) => s === 'strict-only'); + const main = new Set(Object.keys((await loadRuleset(RULESET_PATH)).rules)); + const strict = new Set(Object.keys((await loadRuleset(STRICT_PATH)).rules)); + const strictExtra = new Set(diff(strict, main)); + assertSameSet(strictExtra, contract, 'strict.yaml extra rules vs coverage strict-only:'); +}); + +test('(d) every implemented/partial-proxy/strict-only rule has pass+fail fixtures', opts, () => { + const coverage = loadCoverage(); + const names = unionSpectralRules(coverage, (s) => IMPLEMENTED.has(s) || s === 'strict-only'); + const missing = []; + for (const name of names) { + const dir = join(FIXTURES, name); + if (!existsSync(join(dir, 'pass.yaml')) || !existsSync(join(dir, 'fail.yaml'))) missing.push(name); + } + assert.equal(missing.length, 0, `spectral rules without pass+fail fixtures: ${JSON.stringify(missing.sort())}`); +}); diff --git a/api-design-guide/linter/tests/driver-fixtures/mini-ruleset.yaml b/api-design-guide/linter/tests/driver-fixtures/mini-ruleset.yaml new file mode 100644 index 0000000..0507ef2 --- /dev/null +++ b/api-design-guide/linter/tests/driver-fixtures/mini-ruleset.yaml @@ -0,0 +1,22 @@ +# Self-contained ruleset for driver tests ONLY. +# Two inline rules using built-in Spectral functions (no custom function files), so the +# driver tests do not depend on the real ruleset being finished. Rule codes carry the +# `govstack--` shape the driver strips to a guide rule id (e.g. 2.5). +documentationUrl: https://example.org/govstack-guide +rules: + govstack-2.5-contact: + description: "info block must declare a contact" + message: "info.contact is required (§2.5)" + given: "$.info" + severity: error + then: + field: contact + function: truthy + govstack-2.5-description: + description: "info block must declare a description" + message: "info.description is required (§2.5)" + given: "$.info" + severity: warn + then: + field: description + function: truthy diff --git a/api-design-guide/linter/tests/driver.test.mjs b/api-design-guide/linter/tests/driver.test.mjs new file mode 100644 index 0000000..3f9a4f1 --- /dev/null +++ b/api-design-guide/linter/tests/driver.test.mjs @@ -0,0 +1,873 @@ +// End-to-end tests for the repository-level conformance driver. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.join(HERE, '..', 'cli.mjs'); +const MINI_RULESET = path.join(HERE, 'driver-fixtures', 'mini-ruleset.yaml'); +const GUIDE_VERSION = '0.1.0-draft'; +const ADVISORY = ['--mode', 'advisory', '--skip-validators']; + +function makeRepo(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'govstack-driver-')); + fs.mkdirSync(path.join(dir, '.git'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'api'), { recursive: true }); + for (const [rel, contents] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, contents); + } + if (files['api/coverage.yaml'] && !files['spec/requirements.md']) { + const ids = [ + ...files['api/coverage.yaml'].matchAll( + /\bid:\s*["']?(govstack-[a-z0-9]+(?:[-.][a-z0-9]+)*#req-([1-9][0-9]*))["']?/g, + ), + ].map((match) => ({ id: match[1], number: match[2] })); + if (ids.length) { + const requirements = [...new Map(ids.map((item) => [item.id, item])).values()] + .map( + ({ id, number }) => + `### #${number} Test requirement ${number} (REQUIRED EXTENSIBLE OBSERVABLE)\n\n` + + `\`${id}\`\n\nTest requirement ${number}.`, + ) + .join('\n'); + const abs = path.join(dir, 'spec', 'requirements.md'); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, `${requirements}\n`); + } + } + return dir; +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function runCli(args, { env } = {}) { + const result = spawnSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + env: env ?? process.env, + }); + return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +function runCliJson(args, opts) { + const r = runCli(['--format', 'json', ...args], opts); + let json = null; + try { + json = JSON.parse(r.stdout); + } catch { + // Assertions report stdout/stderr where JSON is expected. + } + return { ...r, json }; +} + +function fakeOpenapiValidator(dir) { + const bin = path.join(dir, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + const validator = path.join(bin, 'openapi-spec-validator'); + fs.writeFileSync(validator, '#!/bin/sh\nexit 0\n'); + fs.chmodSync(validator, 0o755); + return bin; +} + +const VALID_COVERAGE = `version: 1 +requirements: + - id: "govstack-bb-test-fr#req-1" + disposition: external + reference: https://example.org/requirements/test +`; + +const NO_API_INDEX = `version: 1 +noApi: true +reason: This building block exposes no API surface. +`; + +const apiGuide = (version = GUIDE_VERSION, exceptions = '') => ` + x-govstack-api-guide: + version: ${version} + rulesetVersion: ${version}${exceptions}`; + +const cleanOpenapi = (version = GUIDE_VERSION) => `openapi: 3.1.0 +info: + title: Demo + version: 1.0.0 + description: A demo API + contact: + name: Team${apiGuide(version)} +paths: + /v1/things: + get: + operationId: listThings + responses: + '200': { description: ok } +`; + +const openapiMissingContact = (exceptions = '', version = GUIDE_VERSION) => `openapi: 3.1.0 +info: + title: Demo + version: 1.0.0 + description: A demo API${apiGuide(version, exceptions)} +paths: {} +`; + +const cleanAsyncapi = (operation = 'receiveThing', message = 'ThingReceived') => `asyncapi: 3.0.0 +info: + title: Events + version: 1.0.0 + description: Event API + contact: + name: Team${apiGuide()} +channels: + things: + address: things + messages: + ${message}: + $ref: '#/components/messages/${message}' +operations: + ${operation}: + action: receive + channel: { $ref: '#/channels/things' } +components: + messages: + ${message}: + payload: { type: object } +`; + +function codes(report) { + return report.files.flatMap((file) => file.findings.map((finding) => finding.code)); +} + +test('conformance requires an API declaration or explicit noApi', () => { + const missing = makeRepo({}); + const explicit = makeRepo({ 'api/index.yaml': NO_API_INDEX }); + try { + const fail = runCliJson(['--repo-root', missing, '--ruleset', MINI_RULESET]); + assert.equal(fail.status, 1, fail.stderr); + assert.ok(codes(fail.json).includes('api-declaration-required')); + + const pass = runCliJson(['--repo-root', explicit, '--ruleset', MINI_RULESET]); + assert.equal(pass.status, 0, pass.stderr); + assert.equal(pass.json.summary.filesLinted, 0); + assert.match(pass.json.notices.join('\n'), /explicitly declares/); + } finally { + cleanup(missing); + cleanup(explicit); + } +}); + +test('advisory mode permits discovery work without an API declaration', () => { + const dir = makeRepo({}); + try { + const r = runCli(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /No API declaration found/); + } finally { + cleanup(dir); + } +}); + +test('api/index.yaml supports multiple declared OpenAPI and AsyncAPI surfaces', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - type: openapi + path: api/public.yaml + - type: asyncapi + path: api/events.yaml +`, + 'api/public.yaml': cleanOpenapi(), + 'api/events.yaml': cleanAsyncapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - id: "govstack-bb-test-fr#req-1" + disposition: operation + operations: [listThings, receiveThing] + - id: "govstack-bb-test-fr#req-2" + disposition: message + messages: [ThingReceived] +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + assert.equal(r.json.summary.filesLinted, 2); + } finally { + cleanup(dir); + } +}); + +test('api/index.yaml supports a protocol-standard surface without synthetic OpenAPI', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - type: standard + name: OpenID Connect + reference: https://openid.net/specs/openid-connect-core-1_0.html + discovery: /.well-known/openid-configuration +`, + 'api/coverage.yaml': `version: 1 +requirements: + - id: "govstack-bb-test-fr#req-1" + disposition: external + reference: https://openid.net/specs/openid-connect-core-1_0.html +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + assert.equal(r.json.summary.filesLinted, 0); + assert.ok(r.json.notices.some((notice) => notice.includes('Standard-defined API surface'))); + } finally { + cleanup(dir); + } +}); + +test('protocol-standard surfaces remain blocked in conformance until a registry is approved', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - type: standard + name: Example protocol + reference: https://example.org/specification +`, + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('standard-surface-unverified')); + } finally { + cleanup(dir); + } +}); + +test('standard surface inventory requires an HTTPS normative reference', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - type: standard + name: OpenID Connect + reference: http://example.org/oidc +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('api-index-invalid')); + } finally { + cleanup(dir); + } +}); + +test('api/index.yaml rejects a noApi field alongside declared surfaces', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +noApi: false +apis: + - type: openapi + path: api/openapi.yaml +`, + 'api/openapi.yaml': cleanOpenapi(), + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('api-index-invalid')); + } finally { + cleanup(dir); + } +}); + +test('missing and empty index-declared specs fail', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - { type: openapi, path: api/missing.yaml } + - { type: asyncapi, path: api/empty.yaml } +`, + 'api/empty.yaml': ' \n', + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('declared-spec-missing')); + assert.ok(codes(r.json).includes('declared-spec-empty')); + } finally { + cleanup(dir); + } +}); + +test('legacy swagger files fail whether populated or empty', () => { + const dir = makeRepo({ + 'api/index.yaml': NO_API_INDEX, + 'api/swagger.yaml': '', + 'api/swagger.json': '{"openapi":"3.1.0"}', + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET]); + assert.equal(r.status, 1); + assert.equal(codes(r.json).filter((code) => code === 'file-canonical-name').length, 2); + } finally { + cleanup(dir); + } +}); + +test('undeclared parsed specs in api/ and spec assets are blocking duplicates', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + 'api/copy.yaml': cleanOpenapi(), + 'spec/.gitbook/assets/copy.json': '{"asyncapi":"3.0.0","info":{"title":"copy"}}', + 'examples/ignored.yaml': cleanOpenapi(), + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + const paths = r.json.files + .filter((file) => file.findings.some((finding) => finding.code === 'file-undeclared-spec')) + .map((file) => file.path) + .sort(); + assert.deepEqual(paths, ['api/copy.yaml', 'spec/.gitbook/assets/copy.json']); + } finally { + cleanup(dir); + } +}); + +test('operation-free component libraries under api/common are not API surfaces', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + 'api/common/openapi-components.yaml': `openapi: 3.1.0 +info: { title: Common OpenAPI components, version: 1.0.0 } +paths: {} +components: + schemas: + Identifier: { type: string } +`, + 'api/common/asyncapi-components.yaml': `asyncapi: 3.0.0 +info: { title: Common AsyncAPI components, version: 1.0.0 } +channels: {} +operations: {} +components: + schemas: + Identifier: { type: string } +`, + 'api/common/hidden-surface.yaml': cleanOpenapi(), + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + const paths = r.json.files + .filter((file) => file.findings.some((finding) => finding.code === 'file-undeclared-spec')) + .map((file) => file.path); + assert.deepEqual(paths, ['api/common/hidden-surface.yaml']); + } finally { + cleanup(dir); + } +}); + +test('coverage requires stable unique IDs and valid dispositions', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - { id: bad, disposition: operation, operations: [missingOperation] } + - { id: bad, disposition: planned } +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('coverage-invalid')); + assert.ok(codes(r.json).includes('coverage-missing-reference')); + } finally { + cleanup(dir); + } +}); + +test('coverage rejects ambiguous operation and message names across surfaces', () => { + const dir = makeRepo({ + 'api/index.yaml': `version: 1 +apis: + - { type: asyncapi, path: api/a.yaml } + - { type: asyncapi, path: api/b.yaml } +`, + 'api/a.yaml': cleanAsyncapi('sameOperation', 'SameMessage'), + 'api/b.yaml': cleanAsyncapi('sameOperation', 'SameMessage'), + 'api/coverage.yaml': `version: 1 +requirements: + - { id: "govstack-bb-test-fr#req-1", disposition: operation, operations: [sameOperation] } +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.equal(codes(r.json).filter((code) => code === 'coverage-ambiguous-reference').length, 2); + } finally { + cleanup(dir); + } +}); + +test('coverage is an exact projection of keyed Markdown requirements and flags legacy lines', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - { id: "govstack-bb-test-fr#req-1", disposition: external, reference: https://example.org/one } + - { id: "govstack-bb-extra-fr#req-1", disposition: external, reference: https://example.org/extra } +`, + 'spec/requirements.md': `# Requirements + +### #1 The API exposes the first contract (REQUIRED EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-1\` + +The API exposes the first contract. + +### #2 The API exposes the second contract (RECOMMENDED EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-2\` + +The API exposes the second contract. + +- **OLD-FR-003** **OPTIONAL**: Old prose requirement. +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('coverage-unknown-requirement')); + assert.ok(codes(r.json).includes('coverage-missing-requirement')); + assert.ok(codes(r.json).includes('requirements-unkeyed')); + } finally { + cleanup(dir); + } +}); + +test('draft, deprecated, and inapplicable requirements are not active coverage obligations', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + 'spec/requirements.md': `# Requirements + +### #1 Active requirement (REQUIRED EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-1\` + +Active contract. + +### #2 Proposed requirement (DRAFT EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-2\` + +Proposed contract. + +### #3 Retired requirement (DEPRECATED REPLACEABLE AUDITABLE) + +\`govstack-bb-test-fr#req-3\` + +Retired contract. + +### #4 Inherited exclusion (RECOMMENDED INAPPLICABLE AUDITABLE) + +\`govstack-bb-test-cfr#req-4 replaces govstack-cfr-quality#req-4\` + +The parent does not apply because this surface uses a protocol-native contract. +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + } finally { + cleanup(dir); + } +}); + +test('planned coverage is blocking in conformance and advisory-only in advisory mode', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - id: "govstack-bb-test-fr#req-1" + disposition: planned + issue: https://example.org/issues/123 +`, + }); + try { + const bin = fakeOpenapiValidator(dir); + const conform = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET], { + env: { ...process.env, PATH: bin }, + }); + assert.equal(conform.status, 1); + const conformanceFinding = conform.json.files + .flatMap((file) => file.findings) + .find((finding) => finding.code === 'coverage-planned'); + assert.equal(conformanceFinding.severity, 'error'); + + const advisory = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(advisory.status, 0, `${advisory.stderr}\n${advisory.stdout}`); + const advisoryFinding = advisory.json.files + .flatMap((file) => file.findings) + .find((finding) => finding.code === 'coverage-planned'); + assert.equal(advisoryFinding.severity, 'warn'); + } finally { + cleanup(dir); + } +}); + +test('coverage rejects disposition-incompatible extra fields', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - id: "govstack-bb-test-fr#req-1" + disposition: external + reference: https://example.org/requirement + operations: [listThings] +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('coverage-invalid')); + } finally { + cleanup(dir); + } +}); + +test('coverage can trace a REQUIRED requirement to non-API verification', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - id: "govstack-bb-test-fr#req-1" + disposition: non-api + rationale: This requirement is verified through the BB audit procedure. +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + } finally { + cleanup(dir); + } +}); + +test('coverage accepts version-qualified CFR requirement identifiers', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': `version: 1 +requirements: + - id: "govstack-bb-test-fr-2.3.0#req-7" + disposition: external + reference: https://example.org/requirements/7 +`, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + } finally { + cleanup(dir); + } +}); + +test('requirement markers require an exact level-three heading and body text', () => { + const wrongDepth = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'spec/requirements.md': `## #1 Wrong depth (REQUIRED EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-1\` + +This requirement has body text. +`, + }); + const missingBody = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'spec/requirements.md': `### #1 Empty requirement (REQUIRED EXTENSIBLE OBSERVABLE) + +\`govstack-bb-test-fr#req-1\` + +### Notes +`, + }); + try { + const depthResult = runCliJson(['--repo-root', wrongDepth, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(depthResult.status, 1); + assert.ok(codes(depthResult.json).includes('requirements-invalid-marker')); + + const bodyResult = runCliJson(['--repo-root', missingBody, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(bodyResult.status, 1); + assert.ok(codes(bodyResult.json).includes('requirements-invalid-marker')); + } finally { + cleanup(wrongDepth); + cleanup(missingBody); + } +}); + +test('valid reviewed, unexpired exception suppresses only its known guide rule', () => { + const exception = ` + exceptions: + - rule: "2.5" + scope: "/info" + rationale: "Temporary compatibility with the approved legacy owner." + record: "https://example.org/exceptions/EXC-001" + reviewedBy: "API Working Group" + reviewedAt: "2026-07-10" + expiresAt: "2099-12-31"`; + const dir = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(exception), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 0, `${r.stderr}\n${r.stdout}`); + assert.ok(!codes(r.json).includes('govstack-2.5-contact')); + const suppressed = r.json.files.flatMap((file) => file.suppressed); + assert.equal(suppressed[0].exceptionRecord, 'https://example.org/exceptions/EXC-001'); + } finally { + cleanup(dir); + } +}); + +test('bare, unknown, or expired exceptions never suppress findings', () => { + const exception = ` + exceptions: + - "2.5" + - rule: "999.1" + scope: "/info" + rationale: "This rule does not exist and cannot be excepted." + record: "https://example.org/exceptions/EXC-002" + reviewedBy: "API Working Group" + reviewedAt: "2020-01-01" + expiresAt: "2020-12-31"`; + const dir = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(exception), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 1); + assert.ok(codes(r.json).includes('guide-exception')); + assert.ok(codes(r.json).includes('govstack-2.5-contact')); + assert.equal(r.json.summary.suppressed, 0); + } finally { + cleanup(dir); + } +}); + +test('exception scope must contain the finding path; root scope contains all descendants', () => { + const exception = (scope) => ` + exceptions: + - rule: "2.5" + scope: ${JSON.stringify(scope)} + rationale: "Temporary compatibility with the approved legacy owner." + record: "https://example.org/exceptions/EXC-SCOPE" + reviewedBy: "API Working Group" + reviewedAt: "2026-07-10" + expiresAt: "2099-12-31"`; + const wrong = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(exception('/paths')), + 'api/coverage.yaml': VALID_COVERAGE, + }); + const root = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(exception('')), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const wrongResult = runCliJson(['--repo-root', wrong, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(wrongResult.status, 1); + assert.ok(codes(wrongResult.json).includes('govstack-2.5-contact')); + + const rootResult = runCliJson(['--repo-root', root, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(rootResult.status, 0, `${rootResult.stderr}\n${rootResult.stdout}`); + assert.equal(rootResult.json.summary.suppressed, 1); + } finally { + cleanup(wrong); + cleanup(root); + } +}); + +test('HTTP exception records and missing rulesetVersion fail conformance', () => { + const insecureException = ` + exceptions: + - rule: "2.5" + scope: "/info" + rationale: "Temporary compatibility with the approved legacy owner." + record: "http://example.org/exceptions/EXC-HTTP" + reviewedBy: "API Working Group" + reviewedAt: "2026-07-10" + expiresAt: "2099-12-31"`; + const insecure = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(insecureException), + 'api/coverage.yaml': VALID_COVERAGE, + }); + const missingRuleset = makeRepo({ + 'api/openapi.yaml': `openapi: 3.1.0 +info: + title: Demo + version: 1.0.0 + description: Demo + contact: { name: Team } + x-govstack-api-guide: + version: ${GUIDE_VERSION} +paths: {} +`, + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const insecureResult = runCliJson(['--repo-root', insecure, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(insecureResult.status, 1); + assert.ok(codes(insecureResult.json).includes('guide-exception')); + assert.ok(codes(insecureResult.json).includes('govstack-2.5-contact')); + + const bin = fakeOpenapiValidator(missingRuleset); + const versionResult = runCliJson(['--repo-root', missingRuleset, '--ruleset', MINI_RULESET], { + env: { ...process.env, PATH: bin }, + }); + assert.equal(versionResult.status, 1); + assert.ok(codes(versionResult.json).includes('guide-version')); + } finally { + cleanup(insecure); + cleanup(missingRuleset); + } +}); + +test('conformance fails an unsupported guide version and accepts the exact draft version', () => { + const mismatch = makeRepo({ + 'api/openapi.yaml': cleanOpenapi('0.2.0'), + 'api/coverage.yaml': VALID_COVERAGE, + }); + const exact = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const mismatchBin = fakeOpenapiValidator(mismatch); + const exactBin = fakeOpenapiValidator(exact); + const fail = runCliJson(['--repo-root', mismatch, '--ruleset', MINI_RULESET], { + env: { ...process.env, PATH: mismatchBin }, + }); + assert.equal(fail.status, 1, fail.stderr); + assert.ok(codes(fail.json).includes('guide-version')); + + const pass = runCliJson(['--repo-root', exact, '--ruleset', MINI_RULESET], { + env: { ...process.env, PATH: exactBin }, + }); + assert.equal(pass.status, 0, `${pass.stderr}\n${pass.stdout}`); + } finally { + cleanup(mismatch); + cleanup(exact); + } +}); + +test('missing base validator fails conformance but is an advisory notice', () => { + const dir = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const conform = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET], { + env: { ...process.env, PATH: '' }, + }); + assert.equal(conform.status, 1, conform.stderr); + assert.ok(codes(conform.json).includes('base-validator-unavailable')); + + const advisory = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, '--mode', 'advisory'], { + env: { ...process.env, PATH: '' }, + }); + assert.equal(advisory.status, 0, advisory.stderr); + assert.match(advisory.json.notices.join('\n'), /openapi-spec-validator not found/); + } finally { + cleanup(dir); + } +}); + +test('conformance rejects --skip-validators', () => { + const dir = makeRepo({}); + try { + const r = runCli(['--repo-root', dir, '--ruleset', MINI_RULESET, '--skip-validators']); + assert.equal(r.status, 2); + assert.match(r.stderr, /only available with --mode advisory/); + } finally { + cleanup(dir); + } +}); + +test('--format json includes mode and stable report fields', () => { + const dir = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCliJson(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.ok(r.json, `${r.stderr}\n${r.stdout}`); + assert.deepEqual( + Object.keys(r.json).sort(), + ['failOn', 'failed', 'files', 'mode', 'notices', 'summary'], + ); + assert.equal(r.json.mode, 'advisory'); + assert.equal(r.json.failOn, 'error'); + } finally { + cleanup(dir); + } +}); + +test('--fail-on never reports deterministic findings without failing', () => { + const dir = makeRepo({ + 'api/openapi.yaml': openapiMissingContact(), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCliJson([ + '--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY, '--fail-on', 'never', + ]); + assert.equal(r.status, 0); + assert.equal(r.json.failed, false); + assert.ok(r.json.summary.errors >= 1); + } finally { + cleanup(dir); + } +}); + +test('unparseable declared spec is an operational error naming the file', () => { + const dir = makeRepo({ + 'api/openapi.yaml': 'openapi: 3.1.0\ninfo: {title: [oops\n', + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const r = runCli(['--repo-root', dir, '--ruleset', MINI_RULESET, ...ADVISORY]); + assert.equal(r.status, 2); + assert.match(r.stderr, /Cannot parse spec file api\/openapi\.yaml/); + } finally { + cleanup(dir); + } +}); + +test('bad flag and missing ruleset are operational errors', () => { + const empty = makeRepo({}); + const spec = makeRepo({ + 'api/openapi.yaml': cleanOpenapi(), + 'api/coverage.yaml': VALID_COVERAGE, + }); + try { + const bad = runCli(['--repo-root', empty, '--mode', 'bogus']); + assert.equal(bad.status, 2); + assert.match(bad.stderr, /Invalid --mode/); + + const missing = runCli([ + '--repo-root', spec, '--ruleset', path.join(spec, 'missing.yaml'), ...ADVISORY, + ]); + assert.equal(missing.status, 2); + assert.match(missing.stderr, /Ruleset not found/); + } finally { + cleanup(empty); + cleanup(spec); + } +}); diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.1/fail.yaml new file mode 100644 index 0000000..4976c6a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.1/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.1 (opaque resource identifiers). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + id: + type: integer diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.1/pass.yaml new file mode 100644 index 0000000..561eed9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.1/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.1 (opaque resource identifiers). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + id: + type: string + format: uuid diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.10/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.10/fail.yaml new file mode 100644 index 0000000..e1f8f2e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.10/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.10 (ISO 4217 currency codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + currency: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.10/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.10/pass.yaml new file mode 100644 index 0000000..9aa1e28 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.10/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.10 (ISO 4217 currency codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + currency: + type: string + pattern: '^[A-Z]{3}$' diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/fail.yaml new file mode 100644 index 0000000..66a1f94 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/fail.yaml @@ -0,0 +1,21 @@ +asyncapi: 3.0.0 +info: + title: Section 10.11 fail + version: 1.0.0 + description: contentType names an encoding other than UTF-8. + contact: + name: GovStack Identity BB + url: https://example.org/contact +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + contentType: application/json; charset=iso-8859-1 + payload: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/pass.yaml new file mode 100644 index 0000000..799a35f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.11-asyncapi/pass.yaml @@ -0,0 +1,22 @@ +asyncapi: 3.0.0 +info: + title: Section 10.11 pass + version: 1.0.0 + description: contentType is plain JSON, and an explicit utf-8 charset is allowed. + contact: + name: GovStack Identity BB + url: https://example.org/contact +defaultContentType: application/json +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + contentType: application/json; charset=utf-8 + payload: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/fail.yaml new file mode 100644 index 0000000..d330e64 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/fail.yaml @@ -0,0 +1,16 @@ +openapi: 3.1.0 +info: + title: Section 10.11 fail + version: 1.0.0 +paths: + /v1/records: + get: + operationId: listRecords + responses: + '200': + description: A page of records. + content: + # charset names an encoding other than UTF-8. + application/json; charset=iso-8859-1: + schema: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/pass.yaml new file mode 100644 index 0000000..e32bba5 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.11-openapi/pass.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: Section 10.11 pass + version: 1.0.0 +paths: + /v1/records: + get: + operationId: listRecords + responses: + '200': + description: A page of records. + content: + # No charset parameter, and an explicit utf-8 one: both allowed. + application/json: + schema: + type: object + text/csv; charset=utf-8: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.2/fail.yaml new file mode 100644 index 0000000..656662e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.2/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.2 (RFC 3339 timestamps). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + createdAt: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.2/pass.yaml new file mode 100644 index 0000000..3df8e9a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.2/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.2 (RFC 3339 timestamps). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + createdAt: + type: string + format: date-time diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.3/fail.yaml new file mode 100644 index 0000000..71c53f2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.3/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.3 (RFC 3339 calendar dates). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + birthDate: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.3/pass.yaml new file mode 100644 index 0000000..0d3741e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.3/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.3 (RFC 3339 calendar dates). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + birthDate: + type: string + format: date diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.4/fail.yaml new file mode 100644 index 0000000..caccfca --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.4/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.4 (decimal-string monetary amounts). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + price: + type: number diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.4/pass.yaml new file mode 100644 index 0000000..62daf7a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.4/pass.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.4 (decimal-string monetary amounts). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + price: + type: object + required: [amount, currency] + properties: + amount: + type: string + currency: + type: string + pattern: '^[A-Z]{3}$' diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.5/fail.yaml new file mode 100644 index 0000000..ca3927e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.5/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.5 (E.164 phone numbers). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + phoneNumber: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.5/pass.yaml new file mode 100644 index 0000000..372c6a9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.5/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.5 (E.164 phone numbers). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + phoneNumber: + type: string + pattern: '^\+[1-9]\d{1,14}$' diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.6/fail.yaml new file mode 100644 index 0000000..ee88717 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.6/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.6 (RFC 5322 email addresses). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + email: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.6/pass.yaml new file mode 100644 index 0000000..4ae6350 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.6/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.6 (RFC 5322 email addresses). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + email: + type: string + format: email diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.7/fail.yaml new file mode 100644 index 0000000..18cad44 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.7/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.7 (base64 payloads). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + signature: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.7/pass.yaml new file mode 100644 index 0000000..7095c93 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.7/pass.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.7 (base64 payloads). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + signature: + type: string + contentEncoding: base64 + maxLength: 4096 diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.8/fail.yaml new file mode 100644 index 0000000..7320e60 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.8/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.8 (ISO 3166-1 country codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + country: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.8/pass.yaml new file mode 100644 index 0000000..2418706 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.8/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.8 (ISO 3166-1 country codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + country: + type: string + pattern: '^[A-Z]{2}$' diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.9/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.9/fail.yaml new file mode 100644 index 0000000..4d7a5aa --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.9/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.9 (BCP 47 language codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + language: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-10.9/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-10.9/pass.yaml new file mode 100644 index 0000000..c45108d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-10.9/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-10.9 (BCP 47 language codes). + contact: + name: Sample BB Team +paths: + /v1/widgets/{widgetId}: + get: + operationId: getWidget + summary: Get widget + description: Get widget + tags: [widgets] + parameters: + - name: widgetId + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + language: + type: string + pattern: '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$' diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.1/fail.yaml new file mode 100644 index 0000000..b30d450 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.1/fail.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.1. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/json: + schema: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.1/pass.yaml new file mode 100644 index 0000000..57b03de --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.1/pass.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.1. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, code, traceId, timestamp] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + code: { type: string } + traceId: { type: string } + timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.2-type/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.2-type/fail.yaml new file mode 100644 index 0000000..6ec45a4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.2-type/fail.yaml @@ -0,0 +1,36 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.2-type. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, traceId] + properties: + type: { type: string, format: uri } + title: { type: string } + status: { type: integer } + traceId: { type: string } + example: + type: https://docs.example.gov/problems/personNotFound + title: Person not found + status: 404 + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.2-type/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.2-type/pass.yaml new file mode 100644 index 0000000..7d8c01d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.2-type/pass.yaml @@ -0,0 +1,36 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.2-type. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, traceId] + properties: + type: { type: string, format: uri } + title: { type: string } + status: { type: integer } + traceId: { type: string } + example: + type: https://govstack.global/problems/identity/person-not-found + title: Person not found + status: 404 + traceId: 4bf92f3577b34da6a3ce929d0e0e4736 diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml new file mode 100644 index 0000000..d2f1e7b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.2/fail.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.2. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [traceId, code, timestamp] + properties: + traceId: { type: string } + code: { type: string } + timestamp: { type: string, format: date-time } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml new file mode 100644 index 0000000..bc47a67 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.2/pass.yaml @@ -0,0 +1,36 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.2. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, traceId] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + traceId: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.3/fail.yaml new file mode 100644 index 0000000..0388905 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.3/fail.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.3. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml new file mode 100644 index 0000000..19665f0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.3/pass.yaml @@ -0,0 +1,36 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.3. + contact: {} +paths: + /v1/foos/{id}: + get: + operationId: getFoo + summary: Get a foo + description: Get a foo by id. + tags: [foos] + parameters: + - name: id + in: path + required: true + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + '404': + description: Not found + content: + application/problem+json: + schema: + type: object + required: [type, title, status, traceId] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + traceId: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml new file mode 100644 index 0000000..7fde22f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.4/fail.yaml @@ -0,0 +1,43 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.4. + contact: {} +paths: + /v1/foos: + post: + operationId: createFoo + summary: Create a foo + description: Create a foo. + tags: [foos] + requestBody: + content: + application/json: + schema: { type: object } + responses: + '201': + description: Created + content: + application/json: + schema: { type: object } + '422': + description: Field validation failed + content: + application/problem+json: + schema: + type: object + required: [type, title, status, traceId, errors] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + traceId: { type: string } + errors: + type: array + items: + type: object + required: [pointer] + properties: + pointer: { type: string } + code: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml new file mode 100644 index 0000000..fcf884e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-11.4/pass.yaml @@ -0,0 +1,55 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-11.4. + contact: {} +paths: + /v1/foos: + post: + operationId: createFoo + summary: Create a foo + description: Create a foo. + tags: [foos] + requestBody: + content: + application/json: + schema: { type: object } + responses: + '201': + description: Created + content: + application/json: + schema: { type: object } + '400': + description: Malformed request without field attribution + content: + application/problem+json: + schema: + type: object + required: [type, title, status, traceId] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + traceId: { type: string } + '422': + description: Field validation failed + content: + application/problem+json: + schema: + type: object + required: [type, title, status, traceId, errors] + properties: + type: { type: string } + title: { type: string } + status: { type: integer } + traceId: { type: string } + errors: + type: array + items: + type: object + required: [pointer, message] + properties: + pointer: { type: string } + message: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.1/fail.yaml new file mode 100644 index 0000000..dd50e0d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.1/fail.yaml @@ -0,0 +1,21 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.1. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml new file mode 100644 index 0000000..05a2e0e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.1/pass.yaml @@ -0,0 +1,70 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.1. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + - name: cursor + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor] + properties: + nextCursor: { type: string, nullable: true } + # Guide 12.1 lets a collection whose size the specification itself fixes go + # unpaginated, provided the bound is declared with maxItems. + /v1/supported-locales: + get: + operationId: listSupportedLocales + summary: List supported locales + description: The fixed set of locales this BB serves. + tags: [foos] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items] + properties: + items: + type: array + maxItems: 40 + items: { type: string } + # Guide 12.1 covers endpoints returning collections; the operational liveness + # probe /health is not a collection, so a bare GET must NOT fire this rule. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [health] + security: [] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml new file mode 100644 index 0000000..81a0d72 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.2/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.2. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor] + properties: + nextCursor: { type: string, nullable: true } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml new file mode 100644 index 0000000..bb7ec72 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.2/pass.yaml @@ -0,0 +1,49 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.2. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + - name: cursor + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor] + properties: + nextCursor: { type: string, nullable: true } + # Guide 12.2 covers collection endpoints; the operational liveness probe + # /health is exempt (s12-collectionPagination), so a bare GET must NOT fire. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [health] + security: [] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.3/fail.yaml new file mode 100644 index 0000000..6800c27 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.3/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.3. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + - name: cursor + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml new file mode 100644 index 0000000..818dd59 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.3/pass.yaml @@ -0,0 +1,49 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.3. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + - name: cursor + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor] + properties: + nextCursor: { type: [string, 'null'], minLength: 1 } + # Guide 12.3 covers collection endpoints; the operational liveness probe + # /health is exempt (s12-collectionPagination), so a bare GET must NOT fire. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [health] + security: [] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.4/fail.yaml new file mode 100644 index 0000000..8a8b960 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.4/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.4. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.4/pass.yaml new file mode 100644 index 0000000..d9cdd98 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.4/pass.yaml @@ -0,0 +1,41 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.4. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: pageSize + in: query + schema: { type: integer, default: 20, maximum: 100 } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } + # Guide 12.4 (pageSize bounds) applies to collection endpoints; the + # operational liveness probe /health is exempt in the given, so a bare GET + # with no pageSize must NOT fire this rule. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [health] + security: [] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.6/fail.yaml new file mode 100644 index 0000000..1b6f103 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.6/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.6. + contact: {} +paths: + /v1/admin/foos: + get: + operationId: listAdminFoos + summary: List foos (admin) + description: List foos for admin tooling, offset-paginated. + tags: [foos] + parameters: + - name: offset + in: query + schema: { type: integer, default: 0 } + - name: limit + in: query + schema: { type: integer, default: 20, maximum: 100 } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, offset, limit] + properties: + items: + type: array + items: { type: object } + offset: { type: integer } + limit: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.6/pass.yaml new file mode 100644 index 0000000..86010e6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.6/pass.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.6. + contact: {} +paths: + /v1/admin/foos: + get: + operationId: listAdminFoos + summary: List foos (admin) + description: List foos for admin tooling, offset-paginated. + tags: [foos] + parameters: + - name: offset + in: query + schema: { type: integer, default: 0 } + - name: limit + in: query + schema: { type: integer, default: 20, maximum: 100 } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, offset, limit, total] + properties: + items: + type: array + items: { type: object } + offset: { type: integer } + limit: { type: integer } + total: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/fail.yaml new file mode 100644 index 0000000..e43e831 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.7-grammar. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: sort + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/pass.yaml new file mode 100644 index 0000000..3a3fbff --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.7-grammar/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.7-grammar. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: sort + in: query + schema: { type: string, pattern: '^-?[A-Za-z_][A-Za-z0-9_]*(,-?[A-Za-z_][A-Za-z0-9_]*)*$' } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.7-name/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.7-name/fail.yaml new file mode 100644 index 0000000..61c9d68 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.7-name/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.7-name. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: orderBy + in: query + schema: { type: string, pattern: '^-?[A-Za-z_][A-Za-z0-9_]*(,-?[A-Za-z_][A-Za-z0-9_]*)*$' } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.7-name/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.7-name/pass.yaml new file mode 100644 index 0000000..81c8634 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.7-name/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.7-name. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: sort + in: query + schema: { type: string, pattern: '^-?[A-Za-z_][A-Za-z0-9_]*(,-?[A-Za-z_][A-Za-z0-9_]*)*$' } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.8/fail.yaml new file mode 100644 index 0000000..3d87dd6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.8/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.8. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: 'status[gte]' + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.8/pass.yaml new file mode 100644 index 0000000..5cca17a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.8/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.8. + contact: {} +paths: + /v1/foos: + get: + operationId: listFoos + summary: List foos + description: List foos. + tags: [foos] + parameters: + - name: status + in: query + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + items: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml new file mode 100644 index 0000000..b95c5ce --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/fail.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.9-body. + contact: {} +paths: + /v1/foos/search: + post: + operationId: searchFoos + summary: Search foos + description: Complex filtering over foos. + tags: [foos] + requestBody: + content: + application/json: + schema: + type: object + properties: + filters: + type: object + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor] + properties: + nextCursor: { type: string, nullable: true } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml new file mode 100644 index 0000000..d42c665 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-body/pass.yaml @@ -0,0 +1,42 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.9-body. + contact: {} +paths: + /v1/foos/search: + post: + operationId: searchFoos + summary: Search foos + description: Complex filtering over foos. + tags: [foos] + requestBody: + content: + application/json: + schema: + type: object + properties: + filters: + type: object + pageSize: + type: integer + cursor: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor] + properties: + nextCursor: { type: string, nullable: true } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-response/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-response/fail.yaml new file mode 100644 index 0000000..7291a2d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-response/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.9-response. + contact: {} +paths: + /v1/foos/search: + post: + operationId: searchFoos + summary: Search foos + description: Complex filtering over foos. + tags: [foos] + requestBody: + content: + application/json: + schema: + type: object + properties: + filters: + type: object + pageSize: + type: integer + cursor: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: { type: object } diff --git a/api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml new file mode 100644 index 0000000..0f71913 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-12.9-response/pass.yaml @@ -0,0 +1,42 @@ +openapi: 3.1.0 +info: + title: Fixture API + version: 1.0.0 + description: Fixture for govstack-12.9-response. + contact: {} +paths: + /v1/foos/search: + post: + operationId: searchFoos + summary: Search foos + description: Complex filtering over foos. + tags: [foos] + requestBody: + content: + application/json: + schema: + type: object + properties: + filters: + type: object + pageSize: + type: integer + cursor: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: [items, pageInfo] + properties: + items: + type: array + items: { type: object } + pageInfo: + type: object + required: [nextCursor] + properties: + nextCursor: { type: [string, 'null'], minLength: 1 } diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.1/fail.yaml new file mode 100644 index 0000000..6435b23 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.1/fail.yaml @@ -0,0 +1,20 @@ +# 13.1 fail: a scheme is declared but GET /v1/things is covered by neither a +# root-level nor an operation-level security requirement. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: {} +paths: + /v1/things: + get: + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.1/pass.yaml new file mode 100644 index 0000000..fc30050 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.1/pass.yaml @@ -0,0 +1,21 @@ +# 13.1 pass: a root-level security requirement covers every operation. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +security: + - OAuth: [] +components: + securitySchemes: + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: {} +paths: + /v1/things: + get: + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/fail.yaml new file mode 100644 index 0000000..8c9bd7b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Forbidden OAuth flows fail, version: 1.0.0 } +paths: {} +components: + securitySchemes: + citizenOAuth: + type: oauth2 + flows: + password: + tokenUrl: https://identity.example.org/token + scopes: {} + implicit: + authorizationUrl: https://identity.example.org/authorize + scopes: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/pass.yaml new file mode 100644 index 0000000..c60648c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.2-forbidden-flows/pass.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: { title: Safe OAuth flows pass, version: 1.0.0 } +paths: {} +components: + securitySchemes: + citizenOAuth: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://identity.example.org/authorize + tokenUrl: https://identity.example.org/token + scopes: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.2/fail.yaml new file mode 100644 index 0000000..7b4d9df --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.2/fail.yaml @@ -0,0 +1,19 @@ +# 13.2 fail: no openIdConnect or oauth2 scheme is declared (only an apiKey). +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + ApiKey: + type: apiKey + in: header + name: X-API-Key +paths: + /health: + get: + security: + - ApiKey: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.2/pass.yaml new file mode 100644 index 0000000..e92e7e9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.2/pass.yaml @@ -0,0 +1,18 @@ +# 13.2 pass: an openIdConnect scheme is declared for citizen-facing operations. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OIDC: + type: openIdConnect + openIdConnectUrl: https://example.org/.well-known/openid-configuration +paths: + /v1/things: + get: + security: + - OIDC: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.3/fail.yaml new file mode 100644 index 0000000..efd7ee5 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.3/fail.yaml @@ -0,0 +1,26 @@ +# 13.3 fail: no service-to-service scheme. Only OIDC and an oauth2 scheme whose +# single flow is authorizationCode (not clientCredentials). +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OIDC: + type: openIdConnect + openIdConnectUrl: https://example.org/.well-known/openid-configuration + OAuth: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.org/authorize + tokenUrl: https://example.org/token + scopes: {} +paths: + /v1/things: + get: + security: + - OIDC: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.3/pass.yaml new file mode 100644 index 0000000..2523cc0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.3/pass.yaml @@ -0,0 +1,20 @@ +# 13.3 pass: a mutualTLS scheme is declared for BB-to-BB (service-to-service) calls. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OIDC: + type: openIdConnect + openIdConnectUrl: https://example.org/.well-known/openid-configuration + MutualTLS: + type: mutualTLS +paths: + /v1/things: + get: + security: + - OIDC: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.4/fail.yaml new file mode 100644 index 0000000..19a47ad --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.4/fail.yaml @@ -0,0 +1,22 @@ +# 13.4 fail: an OAuth scope string that follows none of the documented shapes. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: + read_things: read things +paths: + /v1/things: + get: + security: + - OAuth: [read_things] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.4/pass.yaml new file mode 100644 index 0000000..1d2e4cc --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.4/pass.yaml @@ -0,0 +1,23 @@ +# 13.4 pass: scope strings follow the default bb:{bb-code}:{resource}:{action} shape. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: + bb:registry:person:read: read persons + bb:registry:person:write: write persons +paths: + /v1/things: + get: + security: + - OAuth: [bb:registry:person:read] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.5/fail.yaml new file mode 100644 index 0000000..3df0e64 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.5/fail.yaml @@ -0,0 +1,19 @@ +# 13.5 fail: an apiKey scheme carries the credential in a query parameter. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + ApiKey: + type: apiKey + in: query + name: api_key +paths: + /health: + get: + security: + - ApiKey: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.5/pass.yaml new file mode 100644 index 0000000..cfd7e27 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.5/pass.yaml @@ -0,0 +1,19 @@ +# 13.5 pass: the apiKey scheme is carried in a header, not a query or cookie. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +components: + securitySchemes: + ApiKey: + type: apiKey + in: header + name: X-API-Key +paths: + /health: + get: + security: + - ApiKey: [] + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.6/fail.yaml new file mode 100644 index 0000000..cd13e41 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.6/fail.yaml @@ -0,0 +1,20 @@ +# 13.6 fail: an apiKey scheme is applied (via root security) to a data +# operation (GET /v1/users), not just an operational endpoint. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +security: + - ApiKey: [] +components: + securitySchemes: + ApiKey: + type: apiKey + in: header + name: X-API-Key +paths: + /v1/users: + get: + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-13.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-13.6/pass.yaml new file mode 100644 index 0000000..36bb47e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-13.6/pass.yaml @@ -0,0 +1,32 @@ +# 13.6 pass: the apiKey scheme is applied only to /health; data operations use OAuth. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +security: + - OAuth: [] +components: + securitySchemes: + ApiKey: + type: apiKey + in: header + name: X-API-Key + OAuth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: {} +paths: + /health: + get: + security: + - ApiKey: [] + responses: + '200': + description: ok + /v1/users: + get: + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-14.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-14.1/fail.yaml new file mode 100644 index 0000000..88829ac --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-14.1/fail.yaml @@ -0,0 +1,15 @@ +# 14.1 fail: a create-POST (declares 201) does not accept an Idempotency-Key header. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/things: + post: + responses: + '201': + description: created + headers: + Location: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-14.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-14.1/pass.yaml new file mode 100644 index 0000000..9900858 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-14.1/pass.yaml @@ -0,0 +1,21 @@ +# 14.1 pass: the create-POST accepts an Idempotency-Key request header. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/things: + post: + parameters: + - name: Idempotency-Key + in: header + required: true + schema: + type: string + responses: + '201': + description: created + headers: + Location: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.1/fail.yaml new file mode 100644 index 0000000..41a05ac --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.1/fail.yaml @@ -0,0 +1,11 @@ +# 15.1 fail: a 202 Accepted response does not declare a Location header. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/jobs: + post: + responses: + '202': + description: accepted diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.1/pass.yaml new file mode 100644 index 0000000..a42d148 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.1/pass.yaml @@ -0,0 +1,15 @@ +# 15.1 pass: the 202 Accepted response declares a Location header. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/jobs: + post: + responses: + '202': + description: accepted + headers: + Location: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.4/fail.yaml new file mode 100644 index 0000000..953f9e8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.4/fail.yaml @@ -0,0 +1,20 @@ +# 15.4 fail: the Operations pattern is used (an Operation schema exists and a +# job returns 202) but no canonical GET /v{N}/operations/{operationId} exists. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/jobs: + post: + responses: + '202': + description: accepted + headers: + Location: + schema: + type: string +components: + schemas: + Operation: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.4/pass.yaml new file mode 100644 index 0000000..de51260 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.4/pass.yaml @@ -0,0 +1,30 @@ +# 15.4 pass: the canonical GET /v1/operations/{operationId} poll endpoint exists. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/jobs: + post: + responses: + '202': + description: accepted + headers: + Location: + schema: + type: string + /v1/operations/{operationId}: + get: + parameters: + - name: operationId + in: path + required: true + schema: + type: string + responses: + '200': + description: ok +components: + schemas: + Operation: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.5/fail.yaml new file mode 100644 index 0000000..a04c99d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.5/fail.yaml @@ -0,0 +1,12 @@ +# 15.5 fail: an Operation-cancellation path that is not the canonical shape +# (missing the /v{N} version prefix). +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /operations/{operationId}/cancel: + post: + responses: + '202': + description: accepted diff --git a/api-design-guide/linter/tests/fixtures/govstack-15.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-15.5/pass.yaml new file mode 100644 index 0000000..90aa71a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-15.5/pass.yaml @@ -0,0 +1,17 @@ +# 15.5 pass: cancellation is POST /v1/operations/{operationId}/cancel. +openapi: 3.1.0 +info: + title: Registry API + version: 1.0.0 +paths: + /v1/operations/{operationId}/cancel: + post: + parameters: + - name: operationId + in: path + required: true + schema: + type: string + responses: + '202': + description: accepted diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.1/fail.yaml new file mode 100644 index 0000000..9069257 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.1/fail.yaml @@ -0,0 +1,31 @@ +openapi: 3.1.0 +info: + title: Orders API + version: 1.0.0 + description: HTTP push modelled with operation callbacks (should use webhooks). + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/orders: + post: + operationId: createOrder + summary: Create an order + description: Creates an order and pushes status changes to a callback URL. + tags: [orders] + responses: + '201': + description: Created + callbacks: + orderStatus: + '{$request.body#/callbackUrl}': + post: + summary: Order status changed + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.1/pass.yaml new file mode 100644 index 0000000..3fcee58 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.1/pass.yaml @@ -0,0 +1,30 @@ +openapi: 3.1.0 +info: + title: Orders API + version: 1.0.0 + description: HTTP push modelled with top-level webhooks (no callbacks). + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/orders: + post: + operationId: createOrder + summary: Create an order + description: Creates an order. + tags: [orders] + responses: + '201': + description: Created +webhooks: + orderStatusChanged: + post: + summary: Order status changed + requestBody: + content: + application/cloudevents+json: + schema: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml new file mode 100644 index 0000000..fc55483 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.11/fail.yaml @@ -0,0 +1,41 @@ +openapi: 3.1.0 +info: + title: Subscriptions API + version: 1.0.0 + description: Subscription surface is missing a delete interface. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/subscriptions: + post: + operationId: createSubscription + summary: Create a subscription + description: Registers a webhook subscription. + tags: [subscriptions] + responses: + '201': + description: Created + get: + operationId: listSubscriptions + summary: List subscriptions + description: Lists webhook subscriptions. + tags: [subscriptions] + responses: + '200': + description: OK + /v1/subscriptions/{subscriptionId}: + get: + operationId: getSubscription + summary: Get a subscription + description: Returns one webhook subscription but does not provide deletion. + tags: [subscriptions] + parameters: + - name: subscriptionId + in: path + required: true + schema: + type: string + responses: + '204': + description: No Content diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml new file mode 100644 index 0000000..2c09dda --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.11/pass.yaml @@ -0,0 +1,41 @@ +openapi: 3.1.0 +info: + title: Subscriptions API + version: 1.0.0 + description: Subscription surface exposes create/list/delete. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/subscriptions: + post: + operationId: createSubscription + summary: Create a subscription + description: Registers a webhook subscription. + tags: [subscriptions] + responses: + '201': + description: Created + get: + operationId: listSubscriptions + summary: List subscriptions + description: Lists webhook subscriptions. + tags: [subscriptions] + responses: + '200': + description: OK + /v1/subscriptions/{subscriptionId}: + delete: + operationId: deleteSubscription + summary: Delete a subscription + description: Removes a webhook subscription. + tags: [subscriptions] + parameters: + - name: subscriptionId + in: path + required: true + schema: + type: string + responses: + '204': + description: No Content diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml new file mode 100644 index 0000000..b048980 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: CloudEvents envelope omits the recommended time/datacontenttype. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml new file mode 100644 index 0000000..1239b00 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2-recommended/pass.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: CloudEvents envelope includes the recommended time/datacontenttype. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "global.govstack.payments.payment.completed" + time: + type: string + format: date-time + datacontenttype: + type: string + const: "application/json" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/fail.yaml new file mode 100644 index 0000000..bbc91ea --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Webhook is delivered in CloudEvents binary content mode. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/pass.yaml new file mode 100644 index 0000000..7a8226c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2-structured/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Webhook is delivered in CloudEvents structured content mode. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json; charset=utf-8: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2/fail.yaml new file mode 100644 index 0000000..4bd2051 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2/fail.yaml @@ -0,0 +1,30 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Webhook payload is not a valid CloudEvents envelope. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type] + properties: + specversion: + type: string + id: + type: string + source: + type: string + type: + type: string + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml new file mode 100644 index 0000000..f0d6604 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.2/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Webhook payload is a valid CloudEvents v1.0 envelope. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/fail.yaml new file mode 100644 index 0000000..659eade --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Event type embeds a forbidden major-version segment. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + const: "global.govstack.payments.v1.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/pass.yaml new file mode 100644 index 0000000..2839700 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3-no-version/pass.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Event type has no major-version segment. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml new file mode 100644 index 0000000..8ff8139 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3/fail.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Event type does not use the recommended GovStack reverse-DNS shape. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + const: "payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml new file mode 100644 index 0000000..d9a06e4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.3/pass.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: Event type is reverse-DNS with no version segment. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml new file mode 100644 index 0000000..3472a45 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.4/fail.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: CloudEvents source points at a deployment host/env/port. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + examples: + - "https://pay-prod-eu1.internal:8443/events" + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml new file mode 100644 index 0000000..d41374a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-16.4/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Payments Events API + version: 1.0.0 + description: CloudEvents source is a stable BB identifier. + contact: + name: Sample BB Team + url: https://example.org/contact +webhooks: + paymentCompleted: + post: + summary: Payment completed event + requestBody: + content: + application/cloudevents+json: + schema: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + const: "urn:govstack:bb:payments" + type: + type: string + const: "global.govstack.payments.payment.completed" + data: + type: object + responses: + '200': + description: Acknowledged diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml new file mode 100644 index 0000000..6e7166e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.1/fail.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + # 17.1: "publish" is AsyncAPI 2.x wording; 3.0 requires send/receive. + action: publish + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml new file mode 100644 index 0000000..8d3259e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.1/pass.yaml @@ -0,0 +1,25 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml new file mode 100644 index 0000000..afaf6e7 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.10/fail.yaml @@ -0,0 +1,34 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +servers: + production: + host: broker.example.com + protocol: kafka + # 17.10: no server-level security, and the operation declares none either. +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + securitySchemes: + userToken: + type: http + scheme: bearer + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml new file mode 100644 index 0000000..9e72559 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.10/pass.yaml @@ -0,0 +1,35 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +servers: + production: + host: broker.example.com + protocol: kafka + security: + - $ref: '#/components/securitySchemes/userToken' +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + securitySchemes: + userToken: + type: http + scheme: bearer + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml new file mode 100644 index 0000000..dcb7771 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.16/fail.yaml @@ -0,0 +1,61 @@ +asyncapi: 3.0.0 +info: + title: Payments commands + version: 1.0.0 +channels: + submitPayment: + address: global.govstack.pay.v1.payment.submit + messages: + cmd: + $ref: '#/components/messages/SubmitPayment' + rejected: + $ref: '#/components/messages/PaymentRejected' +operations: + onSubmitPayment: + action: receive + channel: + $ref: '#/channels/submitPayment' + messages: + - $ref: '#/channels/submitPayment/messages/cmd' +components: + messages: + # 17.16: an HTTP-shaped problem with status is not a transport-neutral + # GovStackAsyncError and must not satisfy the rejection-message rule. + SubmitPayment: + payload: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + data: + type: object + examples: + - name: sample + payload: {} + PaymentRejected: + contentType: application/json + correlationId: + location: '$message.header#/correlationId' + payload: + type: object + required: [type, title, status] + properties: + type: + type: string + title: + type: string + status: + type: integer + examples: + - name: sample + payload: + type: about:blank + title: Payment rejected + status: 422 diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml new file mode 100644 index 0000000..a6f5638 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.16/pass.yaml @@ -0,0 +1,65 @@ +asyncapi: 3.0.0 +info: + title: Payments commands + version: 1.0.0 +channels: + submitPayment: + address: global.govstack.pay.v1.payment.submit + messages: + cmd: + $ref: '#/components/messages/SubmitPayment' + rejected: + $ref: '#/components/messages/PaymentRejected' +operations: + onSubmitPayment: + action: receive + channel: + $ref: '#/channels/submitPayment' + messages: + - $ref: '#/channels/submitPayment/messages/cmd' +components: + messages: + SubmitPayment: + payload: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + data: + type: object + examples: + - name: sample + payload: {} + PaymentRejected: + # CloudEvents-wrapped §11 error, correlated to the original command. + contentType: application/cloudevents+json + correlationId: + location: '$message.header#/correlationId' + payload: + allOf: + - $ref: './common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + required: [data] + properties: + data: + $ref: './common/govstack-asyncapi-common.yaml#/components/schemas/GovStackAsyncError' + examples: + - name: sample + payload: + specversion: '1.0' + id: f68f9e59-f7aa-4efe-b3a1-4f893c0ce99d + source: urn:govstack:bb:pay + type: global.govstack.pay.payment.rejected + data: + type: about:blank + title: Payment rejected + code: global.govstack.pay.paymentRejected + traceId: 9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c + timestamp: '2026-07-10T12:41:05Z' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml new file mode 100644 index 0000000..28f4985 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.17/fail.yaml @@ -0,0 +1,36 @@ +asyncapi: 3.0.0 +info: + title: Query service + version: 1.0.0 +channels: + personQuery: + address: global.govstack.reg.v1.person.query + messages: + cmd: + payload: + type: object + examples: + - name: q + payload: {} + personQueryReply: + address: global.govstack.reg.v1.person.query-reply + messages: + res: + # 17.17: no correlationId, and the reply declares no address location. + payload: + type: object + examples: + - name: r + payload: {} +operations: + askPerson: + action: send + channel: + $ref: '#/channels/personQuery' + messages: + - $ref: '#/channels/personQuery/messages/cmd' + reply: + channel: + $ref: '#/channels/personQueryReply' + messages: + - $ref: '#/channels/personQueryReply/messages/res' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml new file mode 100644 index 0000000..668b895 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.17/pass.yaml @@ -0,0 +1,37 @@ +asyncapi: 3.0.0 +info: + title: Query service + version: 1.0.0 +channels: + personQuery: + address: global.govstack.reg.v1.person.query + messages: + cmd: + payload: + type: object + examples: + - name: q + payload: {} + personQueryReply: + address: global.govstack.reg.v1.person.query-reply + messages: + res: + correlationId: + location: '$message.header#/correlationId' + payload: + type: object + examples: + - name: r + payload: {} +operations: + askPerson: + action: send + channel: + $ref: '#/channels/personQuery' + messages: + - $ref: '#/channels/personQuery/messages/cmd' + reply: + channel: + $ref: '#/channels/personQueryReply' + messages: + - $ref: '#/channels/personQueryReply/messages/res' diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml new file mode 100644 index 0000000..432b71a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.19/fail.yaml @@ -0,0 +1,31 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +servers: + production: + host: broker.example.com + protocol: kafka +channels: + personCreated: + # 17.19: server speaks kafka, but neither the channel nor its operation + # declares kafka bindings. + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml new file mode 100644 index 0000000..64211c2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.19/pass.yaml @@ -0,0 +1,33 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +servers: + production: + host: broker.example.com + protocol: kafka +channels: + personCreated: + address: global.govstack.reg.v1.person.created + bindings: + kafka: + topic: global.govstack.reg.v1.person.created + bindingVersion: '0.5.0' + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml new file mode 100644 index 0000000..4434f9b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.2/fail.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + # 17.2: the logical ID is not reverse-DNS, even though the native address is valid. + address: registrants/{tenant}/created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml new file mode 100644 index 0000000..da05230 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.2/pass.yaml @@ -0,0 +1,25 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + global.govstack.reg.v1.person.created: + address: registrants/{tenant}/created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/global.govstack.reg.v1.person.created' + messages: + - $ref: '#/channels/global.govstack.reg.v1.person.created/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml new file mode 100644 index 0000000..425d459 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.20/fail.yaml @@ -0,0 +1,23 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + # 17.20: no examples declared for this message. + payload: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml new file mode 100644 index 0000000..d127965 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.20/pass.yaml @@ -0,0 +1,27 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + summary: A person-created event + payload: + id: e-1 diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml new file mode 100644 index 0000000..1611bdb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.3/fail.yaml @@ -0,0 +1,30 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personEmail: + # 17.3: "email" is a directly-identifying attribute in the channel address. + address: global.govstack.reg.v1.person.email + parameters: + # 17.3: personal-data token in a channel parameter name. + phoneNumber: + description: caller phone + messages: + evt: + $ref: '#/components/messages/PersonEvent' +operations: + onPersonEvent: + action: receive + channel: + $ref: '#/channels/personEmail' + messages: + - $ref: '#/channels/personEmail/messages/evt' +components: + messages: + PersonEvent: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml new file mode 100644 index 0000000..2a41e5a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.3/pass.yaml @@ -0,0 +1,30 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + parameters: + tenant: + description: tenant routing key + region: + description: deployment region + messages: + evt: + $ref: '#/components/messages/PersonEvent' +operations: + onPersonEvent: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonEvent: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml new file mode 100644 index 0000000..9c6e1e1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.4/fail.yaml @@ -0,0 +1,29 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.{tenant}.person.created + parameters: + # 17.4: the {tenant} parameter is declared but not documented (no description). + tenant: + enum: [alpha, beta] + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml new file mode 100644 index 0000000..7b7fd79 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.4/pass.yaml @@ -0,0 +1,29 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.{tenant}.person.created + parameters: + tenant: + description: Tenant routing key; selects the owning tenant partition. + enum: [alpha, beta] + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml new file mode 100644 index 0000000..b23804d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.5/fail.yaml @@ -0,0 +1,26 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + # 17.5: environment token "prod" belongs in servers, not the channel address. + address: global.govstack.reg.v1.prod.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml new file mode 100644 index 0000000..8d3259e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.5/pass.yaml @@ -0,0 +1,25 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml new file mode 100644 index 0000000..30823d8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.6/fail.yaml @@ -0,0 +1,31 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + # 17.6: the message declares CloudEvents but carries a bare domain object. + contentType: application/cloudevents+json + payload: + type: object + properties: + personId: + type: string + examples: + - name: sample + payload: + personId: p-1 diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml new file mode 100644 index 0000000..35e48fb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.6/pass.yaml @@ -0,0 +1,58 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + contentType: application/cloudevents+json + payload: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: "1.0" + id: + type: string + source: + type: string + type: + type: string + data: + type: object + properties: + personId: + type: string + examples: + - name: sample + payload: + specversion: "1.0" + id: e-1 + source: global.govstack.reg + type: global.govstack.reg.v1.person.created + data: + personId: p-1 + LocalJsonCommand: + contentType: application/json + payload: + type: object + properties: + command: + type: string + examples: + - name: sample + payload: + command: refresh diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.7/fail.yaml new file mode 100644 index 0000000..6db046e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.7/fail.yaml @@ -0,0 +1,38 @@ +asyncapi: 3.0.0 +info: + title: Shared envelope reuse fail + version: 1.0.0 +channels: + events: + address: registry.events + messages: + event: + $ref: '#/components/messages/RecordCreated' +operations: + receiveEvent: + action: receive + channel: + $ref: '#/channels/events' + messages: + - $ref: '#/channels/events/messages/event' +components: + messages: + RecordCreated: + contentType: application/cloudevents+json + payload: + type: object + required: [specversion, id, source, type, data] + properties: + specversion: + const: '1.0' + id: + type: string + source: + type: string + type: + type: string + data: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.7/pass.yaml new file mode 100644 index 0000000..be96bcf --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.7/pass.yaml @@ -0,0 +1,45 @@ +asyncapi: 3.0.0 +info: + title: Shared envelope reuse pass + version: 1.0.0 +channels: + events: + address: registry.events + messages: + event: + $ref: '#/components/messages/RecordCreated' +operations: + receiveEvent: + action: receive + channel: + $ref: '#/channels/events' + messages: + - $ref: '#/channels/events/messages/event' +components: + messages: + RecordCreated: + contentType: application/cloudevents+json + payload: + allOf: + - $ref: './common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + required: [data] + properties: + type: + const: global.govstack.registry.record.created + data: + type: object + examples: + - name: sample + payload: {} + LocalJsonCommand: + contentType: application/json + payload: + type: object + properties: + command: + type: string + examples: + - name: sample + payload: + command: refresh diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml new file mode 100644 index 0000000..74de578 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.8/fail.yaml @@ -0,0 +1,31 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + headers: + type: object + properties: + # 17.8: header names must be camelCase and must not use the X- prefix. + X-Custom-Header: + type: string + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml new file mode 100644 index 0000000..e1327ad --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.8/pass.yaml @@ -0,0 +1,32 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + headers: + type: object + properties: + idempotencyKey: + type: string + acceptLanguage: + type: string + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml new file mode 100644 index 0000000..e4e532a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.9/fail.yaml @@ -0,0 +1,32 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + headers: + type: object + properties: + # 17.9: localisation header must be exactly acceptLanguage, not the + # HTTP-style hyphenated variant. + Accept-Language: + type: string + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml new file mode 100644 index 0000000..14df048 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-17.9/pass.yaml @@ -0,0 +1,32 @@ +asyncapi: 3.0.0 +info: + title: Registry events + version: 1.0.0 +channels: + personCreated: + address: global.govstack.reg.v1.person.created + messages: + evt: + $ref: '#/components/messages/PersonCreated' +operations: + onPersonCreated: + action: receive + channel: + $ref: '#/channels/personCreated' + messages: + - $ref: '#/channels/personCreated/messages/evt' +components: + messages: + PersonCreated: + headers: + type: object + properties: + acceptLanguage: + type: string + contentLanguage: + type: string + payload: + type: object + examples: + - name: sample + payload: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.1/fail.yaml new file mode 100644 index 0000000..5595128 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.1/fail.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: Sample API + version: v1.0 + description: Fixture for govstack-18.1 — info.version is not SemVer. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.1/pass.yaml new file mode 100644 index 0000000..8c49f3a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.1/pass.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-18.1 — info.version is SemVer. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml new file mode 100644 index 0000000..14f7f4c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/fail.yaml @@ -0,0 +1,45 @@ +# Fixture for govstack-18.2-asyncapi — logical channel ID major (v1) does not +# match info.version's major (2). +asyncapi: 3.0.0 +info: + title: Identity Events + version: 2.0.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + global.govstack.identity.v1.user.signedup: + address: users/signed-up + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/global.govstack.identity.v1.user.signedup' + messages: + - $ref: '#/channels/global.govstack.identity.v1.user.signedup/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml new file mode 100644 index 0000000..bdd8a77 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-asyncapi/pass.yaml @@ -0,0 +1,45 @@ +# Fixture for govstack-18.2-asyncapi — logical channel ID major (v2) matches +# info.version's major (2). +asyncapi: 3.0.0 +info: + title: Identity Events + version: 2.0.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + global.govstack.identity.v2.user.signedup: + address: users/signed-up + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/global.govstack.identity.v2.user.signedup' + messages: + - $ref: '#/channels/global.govstack.identity.v2.user.signedup/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/fail.yaml new file mode 100644 index 0000000..2cee918 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 2.0.0 + description: Fixture for govstack-18.2-openapi — path major (v1) does not match info.version's major (2). + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/pass.yaml new file mode 100644 index 0000000..94e67e6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.2-openapi/pass.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 2.0.0 + description: Fixture for govstack-18.2-openapi — path major (v2) matches info.version's major (2). + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v2/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.5/fail.yaml new file mode 100644 index 0000000..2fc3e27 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.5/fail.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-18.5 — deprecated operation missing Deprecation/Sunset headers. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + deprecated: true + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.5/pass.yaml new file mode 100644 index 0000000..6566ec2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.5/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-18.5 — deprecated operation declares Deprecation/Sunset headers. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + deprecated: true + responses: + '200': + description: OK + headers: + Deprecation: + description: RFC 9745 deprecation timestamp. + schema: + type: string + Sunset: + description: RFC 8594 planned removal date. + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml new file mode 100644 index 0000000..5548d37 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/fail.yaml @@ -0,0 +1,51 @@ +# Fixture for govstack-18.7-description — x-govstack-deprecated shape is +# complete but the description never mentions the deprecation. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + description: Emitted when a user completes registration. + x-govstack-deprecated: + since: '2025-01-01' + sunset: '2025-07-01' + replacement: UserRegistered + reason: Superseded by a richer event schema. + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml new file mode 100644 index 0000000..e00ef7e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7-description/pass.yaml @@ -0,0 +1,50 @@ +# Fixture for govstack-18.7-description — description mentions the deprecation. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + description: Deprecated; superseded by UserRegistered. + x-govstack-deprecated: + since: '2025-01-01' + sunset: '2025-07-01' + replacement: UserRegistered + reason: Superseded by a richer event schema. + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml new file mode 100644 index 0000000..be2b018 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7/fail.yaml @@ -0,0 +1,49 @@ +# Fixture for govstack-18.7 — x-govstack-deprecated is present but missing +# sunset/replacement/reason. Description already mentions deprecation so this +# fixture isolates the shape violation from govstack-18.7-description. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + description: Deprecated; superseded by UserRegistered. + x-govstack-deprecated: + since: '2025-01-01' + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml new file mode 100644 index 0000000..1498441 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-18.7/pass.yaml @@ -0,0 +1,50 @@ +# Fixture for govstack-18.7 — x-govstack-deprecated carries all four keys. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + description: Deprecated; superseded by UserRegistered. + x-govstack-deprecated: + since: '2025-01-01' + sunset: '2025-07-01' + replacement: UserRegistered + reason: Superseded by a richer event schema. + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. diff --git a/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml new file mode 100644 index 0000000..bab4442 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/fail.yaml @@ -0,0 +1,40 @@ +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.0.0 + description: Fixture for govstack-19.4-asyncapi — acceptLanguage message present but no contentLanguage message on the channel. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + requestMessage: + name: RequestMessage + headers: + type: object + properties: + acceptLanguage: + type: string + payload: + type: object + properties: + userId: + type: string +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/requestMessage' diff --git a/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml new file mode 100644 index 0000000..93cb275 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-19.4-asyncapi/pass.yaml @@ -0,0 +1,53 @@ +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.0.0 + description: Fixture for govstack-19.4-asyncapi — both acceptLanguage and contentLanguage messages present on the channel. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + requestMessage: + name: RequestMessage + headers: + type: object + properties: + acceptLanguage: + type: string + payload: + type: object + properties: + userId: + type: string + responseMessage: + name: ResponseMessage + headers: + type: object + properties: + contentLanguage: + type: string + payload: + type: object + properties: + userId: + type: string +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/requestMessage' + - $ref: '#/channels/userSignedUp/messages/responseMessage' diff --git a/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/fail.yaml new file mode 100644 index 0000000..c1f8ec7 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/fail.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-19.4-openapi — Accept-Language accepted but no response declares Content-Language. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + parameters: + - name: Accept-Language + in: header + required: false + schema: + type: string + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/pass.yaml new file mode 100644 index 0000000..96a96b8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-19.4-openapi/pass.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-19.4-openapi — Accept-Language accepted and Content-Language declared on the response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: [things] + parameters: + - name: Accept-Language + in: header + required: false + schema: + type: string + responses: + '200': + description: OK + headers: + Content-Language: + description: Language of the localised response content. + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml new file mode 100644 index 0000000..153b9b2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.1/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.2.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API on an unqualified later OpenAPI minor version. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml new file mode 100644 index 0000000..a62d2fa --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.1/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.2 +info: + title: Sample API + version: 1.0.0 + description: A sample API on a qualified OpenAPI 3.1 patch version. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/fail.yaml new file mode 100644 index 0000000..913309b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Sample API + version: v1 + description: Version is not SemVer. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/pass.yaml new file mode 100644 index 0000000..bc3c041 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.5-semver/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.2.3 + description: Version is SemVer. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.5/fail.yaml new file mode 100644 index 0000000..1f251bb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.5/fail.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.5/pass.yaml new file mode 100644 index 0000000..e8fb414 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.5/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A complete info block. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.6/fail.yaml new file mode 100644 index 0000000..fe458df --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.6/fail.yaml @@ -0,0 +1,11 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Servers block points at localhost. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: http://localhost:8080/sample/v1 +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml new file mode 100644 index 0000000..fb22e1a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.6/pass.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Parameterised, non-local servers block. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample + description: Non-production reference deployment. + variables: + gatewayHost: + default: gateway.example.org +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.7/fail.yaml new file mode 100644 index 0000000..fbd7852 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.7/fail.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Operation is missing metadata. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/things: + get: + operationId: Get_Things + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-2.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-2.7/pass.yaml new file mode 100644 index 0000000..d259ef5 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-2.7/pass.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Operation carries full metadata. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/things: + get: + operationId: listThings + summary: List things + description: Returns the collection of things. + tags: + - things + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml new file mode 100644 index 0000000..b9cbb3d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/fail.yaml @@ -0,0 +1,15 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-20.3-exceptions — exceptions entries are bare strings, not objects. + contact: + name: Sample BB Team + url: https://example.org/contact + x-govstack-api-guide: + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft + exceptions: + - RULE-9.3 + - RULE-11.2 +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml new file mode 100644 index 0000000..7b58a9f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3-exceptions/pass.yaml @@ -0,0 +1,27 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-20.3-exceptions — exceptions entries are objects referencing an approved exception record. + contact: + name: Sample BB Team + url: https://example.org/contact + x-govstack-api-guide: + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft + exceptions: + - rule: "9.3" + scope: /components/schemas/Legacy/properties/enabled + rationale: Temporary compatibility exception for a reviewed legacy boolean field. + record: https://example.org/exceptions/RULE-9.3 + reviewedBy: API Working Group + reviewedAt: "2026-07-10" + expiresAt: "2099-12-31" + - rule: "11.2" + scope: /paths/~1v1~1things/get/responses/400 + rationale: Temporary compatibility exception for a reviewed problem-details field. + record: https://example.org/exceptions/RULE-11.2 + reviewedBy: API Working Group + reviewedAt: "2026-07-10" + expiresAt: "2099-12-31" +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3/fail.yaml new file mode 100644 index 0000000..a29f5fb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3/fail.yaml @@ -0,0 +1,9 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-20.3 — info lacks the x-govstack-api-guide conformance declaration. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml new file mode 100644 index 0000000..5118405 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-20.3/pass.yaml @@ -0,0 +1,12 @@ +openapi: 3.0.3 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-20.3 — info declares a SemVer x-govstack-api-guide.version. + contact: + name: Sample BB Team + url: https://example.org/contact + x-govstack-api-guide: + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft +paths: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.1/fail.yaml new file mode 100644 index 0000000..d7d8a2a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.1/fail.yaml @@ -0,0 +1,16 @@ +# 3.1 fail: a legacy AsyncAPI 2.x document (detected as aas2) — must be told to +# move to 3.0.0. asyncapi is not "3.0.0". +asyncapi: 2.6.0 +info: + title: Legacy Events + version: 1.0.0 + description: A legacy 2.x event document that must migrate to AsyncAPI 3.0.0. + contact: + name: GovStack Identity BB + url: https://example.org/contact +channels: + userSignedUp: + subscribe: + message: + payload: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml new file mode 100644 index 0000000..1a54b64 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.1/pass.yaml @@ -0,0 +1,49 @@ +# Valid AsyncAPI 3.1.0 document: no §3 finding should fire. 3.0.0 is the other +# qualified version and is covered by tests/golden/asyncapi-golden.yaml. +asyncapi: 3.1.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml new file mode 100644 index 0000000..57a149d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/fail.yaml @@ -0,0 +1,39 @@ +# 3.5 fail (semver): info.version is present but not a SemVer string. +asyncapi: 3.0.0 +info: + title: Identity Events + version: "1.0" + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + userId: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml new file mode 100644 index 0000000..0a4d16f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5-semver/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml new file mode 100644 index 0000000..e1a0a05 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5/fail.yaml @@ -0,0 +1,35 @@ +# 3.5 fail: info block is missing the required description. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 +servers: + production: + host: '{brokerHost}' + protocol: kafka +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + userId: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml new file mode 100644 index 0000000..0a4d16f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.5/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml new file mode 100644 index 0000000..ef8141c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/fail.yaml @@ -0,0 +1,39 @@ +# 3.6 fail (host): a server points at localhost. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: 'localhost:9092' + protocol: kafka +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + userId: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml new file mode 100644 index 0000000..0a4d16f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6-host/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml new file mode 100644 index 0000000..aed049f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6/fail.yaml @@ -0,0 +1,30 @@ +# 3.6 fail: `operations` is empty — a document with only schemas and channels is +# not an API contract. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: {} +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + userId: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml new file mode 100644 index 0000000..0a4d16f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.6/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml new file mode 100644 index 0000000..2df0bd1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.7/fail.yaml @@ -0,0 +1,36 @@ +# 3.7 fail: the operation is missing its required description. Its action, +# channel reference and message reference are otherwise valid and resolvable. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + userId: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml new file mode 100644 index 0000000..0a4d16f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.7/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml new file mode 100644 index 0000000..7b98e90 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.9/fail.yaml @@ -0,0 +1,45 @@ +# 3.9 fail: a message payload declares non-camelCase property names (user_id +# snake_case, SignedUpAt PascalCase), violating the §9 JSON conventions. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + payload: + type: object + properties: + user_id: + type: string + description: Identifier of the user. + SignedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml new file mode 100644 index 0000000..0a4d16f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-3.9/pass.yaml @@ -0,0 +1,48 @@ +# Valid AsyncAPI 3.0.0 document: no §3 finding should fire. +asyncapi: 3.0.0 +info: + title: Identity Events + version: 1.2.0 + description: Emits identity lifecycle events for the Identity building block. + contact: + name: GovStack Identity BB + url: https://example.org/contact +servers: + production: + host: '{brokerHost}' + protocol: kafka + variables: + brokerHost: + default: broker.example.org +channels: + userSignedUp: + address: global.govstack.identity.v1.user.signedup + messages: + userSignedUp: + $ref: '#/components/messages/UserSignedUp' +operations: + receiveUserSignedUp: + action: receive + summary: Receive user signed-up events. + description: Triggered when a user completes registration. + tags: + - name: user + channel: + $ref: '#/channels/userSignedUp' + messages: + - $ref: '#/channels/userSignedUp/messages/userSignedUp' +components: + messages: + UserSignedUp: + name: UserSignedUp + title: User Signed Up + payload: + type: object + properties: + userId: + type: string + description: Identifier of the user. + signedUpAt: + type: string + format: date-time + description: When the signup completed. diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.1/fail.yaml new file mode 100644 index 0000000..e7110b7 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.1/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets. + tags: [widgets] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + properties: + id: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.1/pass.yaml new file mode 100644 index 0000000..cd9e932 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.1/pass.yaml @@ -0,0 +1,36 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets. + tags: [widgets] + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + description: A single widget resource. + properties: + id: + type: string + description: The widget's unique identifier. diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.2/fail.yaml new file mode 100644 index 0000000..848e7e3 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.2/fail.yaml @@ -0,0 +1,43 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + post: + operationId: createWidget + summary: Create widget + description: Creates a widget. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + description: A widget to create. + properties: + status: + type: string + enum: [active, retired] + responses: + '201': + description: Created + content: + application/json: + schema: + type: object + description: The created widget. + properties: + status: + type: string + description: Widget status. + enum: [active, retired] diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.2/pass.yaml new file mode 100644 index 0000000..835fa4b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.2/pass.yaml @@ -0,0 +1,48 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + post: + operationId: createWidget + summary: Create widget + description: Creates a widget. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + description: A widget to create. + properties: + status: + type: string + description: "Widget status: active (in use) or retired (decommissioned)." + enum: [active, retired] + example: + status: active + responses: + '201': + description: Created + content: + application/json: + schema: + type: object + description: The created widget. + properties: + status: + type: string + description: "Widget status: active (in use) or retired (decommissioned)." + enum: [active, retired] + example: + status: active diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.3/fail.yaml new file mode 100644 index 0000000..eefa87e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.3/fail.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: TBD + tags: [widgets] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.3/pass.yaml new file mode 100644 index 0000000..6f1bc82 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.3/pass.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets. + tags: [widgets] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.4/fail.yaml new file mode 100644 index 0000000..f093166 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.4/fail.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets, sorted by creation date. + tags: [widgets] + responses: + '200': + description: OK + /v1/gadgets: + get: + operationId: listGadgets + summary: List gadgets + description: Returns the collection of widgets, sorted by creation date. + tags: [gadgets] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-4.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-4.4/pass.yaml new file mode 100644 index 0000000..9657512 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-4.4/pass.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets, sorted by creation date. + tags: [widgets] + responses: + '200': + description: OK + /v1/gadgets: + get: + operationId: listGadgets + summary: List gadgets + description: Returns the collection of gadgets, sorted by name. + tags: [gadgets] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.1/fail.yaml new file mode 100644 index 0000000..8efd423 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.1/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with an unversioned path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /events: + get: + operationId: listEvents + summary: List events + description: Returns a page of events. + tags: [Events] + responses: + "200": + description: A page of events. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.1/pass.yaml new file mode 100644 index 0000000..0b31e62 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.1/pass.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a versioned path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events: + get: + operationId: listEvents + summary: List events + description: Returns a page of events. + tags: [Events] + responses: + "200": + description: A page of events. + # Guide 5.9 mandates an UNVERSIONED /health endpoint; 5.1 must not fire on it. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [Health] + security: [] + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.10/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.10/fail.yaml new file mode 100644 index 0000000..f872791 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.10/fail.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: >- + A sample API that hides a business resource under a standard unversioned + path to escape the versioning and collection rules. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + # Guide 5.10: the standard unversioned endpoints are read-only. A mutating + # method here is a business resource on an exempt path. + /.well-known/registrants: + post: + operationId: createRegistrant + summary: Create a registrant + description: Creates a registrant record. + tags: [Registrants] + security: [] + responses: + "201": + description: Created. + content: + application/json: + schema: + type: object + properties: + id: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.10/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.10/pass.yaml new file mode 100644 index 0000000..d2c8954 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.10/pass.yaml @@ -0,0 +1,60 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: >- + A sample API whose standard unversioned endpoints are read-only, and whose + business resources stay on the versioned surface. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /.well-known/oauth-protected-resource: + get: + operationId: getProtectedResourceMetadata + summary: Protected-resource metadata + description: RFC 9728 metadata describing this resource server. + tags: [Discovery] + security: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + resource: { type: string, format: uri } + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/json: + schema: + type: object + properties: + description: { type: string } + # A mutating method is fine where it belongs: on the versioned surface. + /v1/registrants: + post: + operationId: createRegistrant + summary: Create a registrant + description: Creates a registrant record. + tags: [Registrants] + security: [] + responses: + "201": + description: Created. + content: + application/json: + schema: + type: object + properties: + id: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.2/fail.yaml new file mode 100644 index 0000000..3a97b4c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.2/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a singular resource collection name. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/policy: + get: + operationId: listPolicies + summary: List policies + description: Returns a page of policies. + tags: [Policies] + responses: + "200": + description: A page of policies. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.2/pass.yaml new file mode 100644 index 0000000..7011508 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.2/pass.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a plural resource collection name. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/policies: + get: + operationId: listPolicies + summary: List policies + description: Returns a page of policies. + tags: [Policies] + responses: + "200": + description: A page of policies. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.3/fail.yaml new file mode 100644 index 0000000..56ef262 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.3/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a camelCase path segment. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/eventSubscriptions: + get: + operationId: listEventSubscriptions + summary: List event subscriptions + description: Returns a page of event subscriptions. + tags: [Events] + responses: + "200": + description: A page of event subscriptions. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.3/pass.yaml new file mode 100644 index 0000000..56204db --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.3/pass.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a kebab-case path segment. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/event-subscriptions: + get: + operationId: listEventSubscriptions + summary: List event subscriptions + description: Returns a page of event subscriptions. + tags: [Events] + responses: + "200": + description: A page of event subscriptions. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.4/fail.yaml new file mode 100644 index 0000000..dd1fc2a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.4/fail.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a deeply nested path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/policies/{policyId}/documents/{documentId}/pages: + get: + operationId: listPolicyDocumentPages + summary: List policy document pages + description: Returns the pages of a policy document. + tags: [Policies] + parameters: + - name: policyId + in: path + required: true + schema: { type: string } + - name: documentId + in: path + required: true + schema: { type: string } + responses: + "200": + description: A page listing. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.4/pass.yaml new file mode 100644 index 0000000..d4c9c60 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.4/pass.yaml @@ -0,0 +1,40 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a shallow path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/policies/{policyId}: + get: + operationId: getPolicy + summary: Get policy + description: Returns a single policy. + tags: [Policies] + parameters: + - name: policyId + in: path + required: true + schema: { type: string } + responses: + "200": + description: A policy. + # Guide 5.8/15.5 mandate this shape: three raw segments after /v1/, but only + # two non-param levels (operations, cancel). `{param}` must not count, so 5.4 + # must NOT fire here. + /v1/operations/{operationId}/cancel: + post: + operationId: cancelOperation + summary: Cancel an operation + description: Requests cancellation of a long-running operation. + tags: [Operations] + parameters: + - name: operationId + in: path + required: true + schema: { type: string } + responses: + "200": + description: The operation state after cancellation. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.5/fail.yaml new file mode 100644 index 0000000..6b8a77e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.5/fail.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API that identifies a resource via a query parameter. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events: + get: + operationId: getEvent + summary: Get event + description: Returns a single event, identified by a query parameter (anti-pattern). + tags: [Events] + parameters: + - name: eventId + in: query + required: true + schema: { type: string } + responses: + "200": + description: The event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.5/pass.yaml new file mode 100644 index 0000000..cc369c1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.5/pass.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API that identifies a resource via a path parameter. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events/{eventId}: + get: + operationId: getEvent + summary: Get event + description: Returns a single event, identified by a path parameter. + tags: [Events] + parameters: + - name: eventId + in: path + required: true + schema: { type: string } + responses: + "200": + description: The event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.6/fail.yaml new file mode 100644 index 0000000..6f11ce9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.6/fail.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a snake_case query parameter. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events: + get: + operationId: listEvents + summary: List events + description: Returns a page of events. + tags: [Events] + parameters: + - name: created_after + in: query + required: false + schema: { type: string, format: date-time } + responses: + "200": + description: A page of events. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.6/pass.yaml new file mode 100644 index 0000000..162c835 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.6/pass.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a camelCase query parameter. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events: + get: + operationId: listEvents + summary: List events + description: Returns a page of events. + tags: [Events] + parameters: + - name: createdAfter + in: query + required: false + schema: { type: string, format: date-time } + responses: + "200": + description: A page of events. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.7/fail.yaml new file mode 100644 index 0000000..d518430 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.7/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a verb in a CRUD path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/event/new: + post: + operationId: createEvent + summary: Create event + description: Creates a new event (anti-pattern path). + tags: [Events] + responses: + "201": + description: The created event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.7/pass.yaml new file mode 100644 index 0000000..eb8efb2 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.7/pass.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a plain CRUD path. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events: + post: + operationId: createEvent + summary: Create event + description: Creates a new event. + tags: [Events] + responses: + "201": + description: The created event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.8/fail.yaml new file mode 100644 index 0000000..90d3a36 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.8/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with an action endpoint missing the resource identifier. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events/cancel: + post: + operationId: cancelEvent + summary: Cancel event + description: Cancels an event without identifying which one (anti-pattern path). + tags: [Events] + responses: + "200": + description: The cancelled event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.8/pass.yaml new file mode 100644 index 0000000..6477978 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.8/pass.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with an action expressed as a sub-resource. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/events/{eventId}/cancel: + post: + operationId: cancelEvent + summary: Cancel event + description: Cancels a single, identified event. + tags: [Events] + parameters: + - name: eventId + in: path + required: true + schema: { type: string } + responses: + "200": + description: The cancelled event. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml new file mode 100644 index 0000000..5962aa7 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/fail.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API whose /health endpoint uses the wrong media type. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/health+json: + schema: + type: object + properties: + description: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml new file mode 100644 index 0000000..3cd57d8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-media-type/pass.yaml @@ -0,0 +1,38 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a compliant /health endpoint. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/json: + schema: + type: object + properties: + description: { type: string } + # Per guide 11.1 an error response is problem+json, not application/json. + # 5.9-media-type is scoped to the 200 response, so it must NOT fire here. + "503": + description: Service is temporarily unable to accept work. + content: + application/problem+json: + schema: + type: object + required: [type, title, status] + properties: + type: { type: string, format: uri } + title: { type: string } + status: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml new file mode 100644 index 0000000..0fb08cc --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/fail.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API whose /health endpoint wrongly requires authentication. + contact: + name: Sample BB Team + url: https://example.org/contact +components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: header + name: X-API-Key +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: + - apiKeyAuth: [] + responses: + "200": + description: Service is healthy. + content: + application/json: + schema: + type: object + properties: + description: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml new file mode 100644 index 0000000..4133299 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-no-auth/pass.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a compliant /health endpoint. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/json: + schema: + type: object + properties: + description: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/fail.yaml new file mode 100644 index 0000000..e38de99 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/fail.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with no /health endpoint. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns a page of widgets. + tags: [Widgets] + responses: + "200": + description: A page of widgets. diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml new file mode 100644 index 0000000..4133299 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-presence/pass.yaml @@ -0,0 +1,25 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a compliant /health endpoint. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/json: + schema: + type: object + properties: + description: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/fail.yaml new file mode 100644 index 0000000..99af979 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/fail.yaml @@ -0,0 +1,27 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API whose /health declares no unhealthy status code. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + # Guide 5.9 carries health in the status code, so a consumer told to + # read it needs a documented 503 as well as the 200. + "200": + description: Service is healthy. + content: + application/json: + schema: + type: object + properties: + description: { type: string } diff --git a/api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/pass.yaml new file mode 100644 index 0000000..dcf525d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-5.9-status-codes/pass.yaml @@ -0,0 +1,36 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: A sample API with a compliant /health endpoint. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: Returns basic liveness status for this BB. + tags: [Health] + security: [] + responses: + "200": + description: Service is healthy. + content: + application/json: + schema: + type: object + properties: + description: { type: string } + "503": + description: Service is temporarily unable to accept work. + content: + application/problem+json: + schema: + type: object + required: [type, title, status] + properties: + type: { type: string, format: uri } + title: { type: string } + status: { type: integer } diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.1/fail.yaml new file mode 100644 index 0000000..848ad53 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.1/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.1/pass.yaml new file mode 100644 index 0000000..6f1bc82 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.1/pass.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + get: + operationId: listWidgets + summary: List widgets + description: Returns the collection of widgets. + tags: [widgets] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.4/fail.yaml new file mode 100644 index 0000000..b4ecee6 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.4/fail.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/{widgetId}: + parameters: + - name: widgetId + in: path + required: true + schema: + type: string + patch: + operationId: updateWidget + summary: Update widget + description: Partially updates a widget. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.4/pass.yaml new file mode 100644 index 0000000..5eec356 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.4/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/{widgetId}: + parameters: + - name: widgetId + in: path + required: true + schema: + type: string + patch: + operationId: updateWidget + summary: Update widget + description: Partially updates a widget. + tags: [widgets] + requestBody: + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.5/fail.yaml new file mode 100644 index 0000000..59e4424 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.5/fail.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/{widgetId}: + parameters: + - name: widgetId + in: path + required: true + schema: + type: string + delete: + operationId: deleteWidget + summary: Delete widget + description: Deletes a widget. + tags: [widgets] + responses: + '204': + description: Deleted + content: + application/json: + schema: + type: object + '201': + description: Somehow created? diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.5/pass.yaml new file mode 100644 index 0000000..d8e6915 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.5/pass.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/{widgetId}: + parameters: + - name: widgetId + in: path + required: true + schema: + type: string + delete: + operationId: deleteWidget + summary: Delete widget + description: Deletes a widget. + tags: [widgets] + responses: + '204': + description: Deleted diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.6/fail.yaml new file mode 100644 index 0000000..ec8552c --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.6/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/search: + post: + operationId: searchWidgets + summary: Search widgets + description: Runs a complex widget search. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + responses: + '201': + description: Search results diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.6/pass.yaml new file mode 100644 index 0000000..fb20923 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.6/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets/search: + post: + operationId: searchWidgets + summary: Search widgets + description: Runs a complex widget search. + tags: [widgets] + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: Search results diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.7/fail.yaml new file mode 100644 index 0000000..0a9ae12 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.7/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + patch: + operationId: bulkUpdateWidgets + summary: Bulk update widgets + description: Partially updates every widget in the collection. + tags: [widgets] + requestBody: + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-6.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-6.7/pass.yaml new file mode 100644 index 0000000..4b7f8b1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-6.7/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Widgets API. + contact: + name: Sample BB Team + url: https://example.org/contact +servers: + - url: https://{gatewayHost}/sample/v1 + variables: + gatewayHost: + default: gateway.example.org +paths: + /v1/widgets: + patch: + operationId: bulkUpdateWidgets + summary: Bulk update widgets + description: Partially updates every widget matching the given status filter. + tags: [widgets] + parameters: + - name: status + in: query + required: true + schema: + type: string + requestBody: + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.13/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.13/fail.yaml new file mode 100644 index 0000000..65c456d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.13/fail.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.13 — operation missing a 500 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.13/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.13/pass.yaml new file mode 100644 index 0000000..3107770 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.13/pass.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.13 — operation declares a 500 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/fail.yaml new file mode 100644 index 0000000..969f879 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: { title: Baseline errors fail, version: 1.0.0 } +security: + - oauth: [] +paths: + /v1/things/{thingId}: + post: + operationId: updateThing + requestBody: + content: + application/json: + schema: { type: object } + responses: + "200": { description: Updated. } diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/pass.yaml new file mode 100644 index 0000000..9dd9bbe --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14-baseline-errors/pass.yaml @@ -0,0 +1,17 @@ +openapi: 3.1.0 +info: { title: Baseline errors pass, version: 1.0.0 } +security: + - oauth: [] +paths: + /v1/things/{thingId}: + post: + operationId: updateThing + requestBody: + content: + application/json: + schema: { type: object } + responses: + "200": { description: Updated. } + "400": { description: Bad request. } + "401": { description: Unauthenticated. } + "404": { description: Not found. } diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/fail.yaml new file mode 100644 index 0000000..e406701 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/fail.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: { title: Creation status fail, version: 1.0.0 } +paths: + /v1/things: + post: + operationId: createThing + responses: + "200": { description: Incorrect creation response. } + "500": { description: Error. } diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/pass.yaml new file mode 100644 index 0000000..6168dd9 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14-creation-status/pass.yaml @@ -0,0 +1,9 @@ +openapi: 3.1.0 +info: { title: Creation status pass, version: 1.0.0 } +paths: + /v1/things: + post: + operationId: createThing + responses: + "201": { description: Created. } + "500": { description: Error. } diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14/fail.yaml new file mode 100644 index 0000000..27b474e --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14/fail.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.14 — operation declares only a 200 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.14/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.14/pass.yaml new file mode 100644 index 0000000..1f49277 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.14/pass.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.14 — operation declares multiple codes including a non-2xx. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.16/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.16/fail.yaml new file mode 100644 index 0000000..d921503 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.16/fail.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.16 — GET 200 response missing ETag and a 304 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.16/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.16/pass.yaml new file mode 100644 index 0000000..254e442 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.16/pass.yaml @@ -0,0 +1,46 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.16 — GET 200 response declares ETag and a 304 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + headers: + ETag: + description: Opaque validator derived from the resource state. + schema: + type: string + '304': + description: Not Modified + '500': + description: Internal Server Error + # Guide 7.16 applies to endpoints that return resources; the operational + # liveness probe /health is exempt, so a bare GET with no ETag/304 must NOT + # fire 7.16. + /health: + get: + operationId: getHealth + summary: Liveness check + description: Unversioned operational liveness probe (guide 5.9). + tags: [health] + security: [] + responses: + '200': + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.17/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.17/fail.yaml new file mode 100644 index 0000000..28b2822 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.17/fail.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.17 — PUT missing If-Match parameter and 412 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + put: + operationId: replaceThing + summary: Replace a thing + description: Replaces a thing in its entirety. + tags: [things] + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.17/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.17/pass.yaml new file mode 100644 index 0000000..de87d83 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.17/pass.yaml @@ -0,0 +1,41 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.17 — PUT declares an If-Match parameter and a 412 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + put: + operationId: replaceThing + summary: Replace a thing + description: Replaces a thing in its entirety. + tags: [things] + parameters: + - name: If-Match + in: header + required: false + description: Conditional update precondition (RFC 9110 If-Match). + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + '200': + description: OK + '412': + description: Precondition Failed + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.18/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.18/fail.yaml new file mode 100644 index 0000000..874e290 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.18/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.18 — 405 response missing an Allow header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '405': + description: Method Not Allowed + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.18/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.18/pass.yaml new file mode 100644 index 0000000..ee4885b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.18/pass.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.18 — 405 response declares an Allow header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '405': + description: Method Not Allowed + headers: + Allow: + description: Methods supported by this resource. + schema: + type: string + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.19/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.19/fail.yaml new file mode 100644 index 0000000..9f6c08a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.19/fail.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.19 — PATCH operation missing a 415 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + patch: + operationId: updateThing + summary: Partially update a thing + description: Applies a merge patch to a thing. + tags: [things] + requestBody: + required: true + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: OK + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.19/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.19/pass.yaml new file mode 100644 index 0000000..23593f8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.19/pass.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.19 — PATCH operation declares a 415 response. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + patch: + operationId: updateThing + summary: Partially update a thing + description: Applies a merge patch to a thing. + tags: [things] + requestBody: + required: true + content: + application/merge-patch+json: + schema: + type: object + responses: + '200': + description: OK + '415': + description: Unsupported Media Type + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.2/fail.yaml new file mode 100644 index 0000000..be050e4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.2/fail.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.2 — 201 response missing a Location header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + post: + operationId: createThing + summary: Create a thing + description: Creates a new thing. + tags: [things] + responses: + '201': + description: Created + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.2/pass.yaml new file mode 100644 index 0000000..b533634 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.2/pass.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.2 — 201 response declares a Location header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things: + post: + operationId: createThing + summary: Create a thing + description: Creates a new thing. + tags: [things] + responses: + '201': + description: Created + headers: + Location: + description: URL of the created thing. + schema: + type: string + format: uri + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.20/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.20/fail.yaml new file mode 100644 index 0000000..3bf6795 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.20/fail.yaml @@ -0,0 +1,32 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.20 — problem+json response missing Cache-Control. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '404': + description: Not Found + content: + application/problem+json: + schema: + type: object + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.20/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.20/pass.yaml new file mode 100644 index 0000000..1a6313d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.20/pass.yaml @@ -0,0 +1,37 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.20 — problem+json response declares Cache-Control. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '404': + description: Not Found + content: + application/problem+json: + schema: + type: object + headers: + Cache-Control: + description: Prevents caches from replaying a stale error. + schema: + type: string + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.21/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.21/fail.yaml new file mode 100644 index 0000000..9595fa0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.21/fail.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: { title: Success schema fail, version: 1.0.0 } +paths: + /v1/things: + get: + responses: + "200": + description: Thing response. + content: + application/json: {} diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.21/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.21/pass.yaml new file mode 100644 index 0000000..765d9a4 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.21/pass.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: { title: Success schema pass, version: 1.0.0 } +paths: + /v1/things: + get: + responses: + "200": + description: Thing response. + content: + application/json: + schema: + type: object + description: A thing. diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.3/fail.yaml new file mode 100644 index 0000000..db5882d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.3/fail.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.3 — 202 response missing a Location header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/exports: + post: + operationId: createExport + summary: Start an export job + description: Starts an asynchronous export job. + tags: [exports] + responses: + '202': + description: Accepted + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.3/pass.yaml new file mode 100644 index 0000000..208ffbb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.3/pass.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.3 — 202 response declares a Location header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/exports: + post: + operationId: createExport + summary: Start an export job + description: Starts an asynchronous export job. + tags: [exports] + responses: + '202': + description: Accepted + headers: + Location: + description: URL of the Operation resource tracking this job (§15). + schema: + type: string + format: uri + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.6/fail.yaml new file mode 100644 index 0000000..ab9370b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.6/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.6 — 401 response missing a WWW-Authenticate header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '401': + description: Unauthorized + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-7.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-7.6/pass.yaml new file mode 100644 index 0000000..1b8a207 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-7.6/pass.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Fixture for govstack-7.6 — 401 response declares a WWW-Authenticate header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /v1/things/{thingId}: + parameters: + - name: thingId + in: path + required: true + schema: + type: string + get: + operationId: getThing + summary: Get a thing + description: Returns a single thing. + tags: [things] + responses: + '200': + description: OK + '401': + description: Unauthorized + headers: + WWW-Authenticate: + description: Challenge for the required authentication scheme. + schema: + type: string + '500': + description: Internal Server Error diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.1/fail.yaml new file mode 100644 index 0000000..6d80498 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.1/fail.yaml @@ -0,0 +1,30 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Credentials travel in the wrong place. + contact: + name: Sample BB Team + url: https://example.org/contact +components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: query + name: api_key +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + parameters: + - name: access_token + in: query + required: true + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.1/pass.yaml new file mode 100644 index 0000000..a6ad5e0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.1/pass.yaml @@ -0,0 +1,30 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Credentials travel via the Authorization header. + contact: + name: Sample BB Team + url: https://example.org/contact +components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: header + name: Authorization +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + parameters: + - name: id + in: query + required: false + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.2/fail.yaml new file mode 100644 index 0000000..89af8fb --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.2/fail.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Accepts Accept-Language but never echoes Content-Language. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /messages: + get: + operationId: listMessages + summary: List messages + description: List localised messages. + tags: [messages] + parameters: + - name: Accept-Language + in: header + required: false + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.2/pass.yaml new file mode 100644 index 0000000..6da44d8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.2/pass.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Echoes Content-Language when Accept-Language is accepted. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /messages: + get: + operationId: listMessages + summary: List messages + description: List localised messages. + tags: [messages] + parameters: + - name: Accept-Language + in: header + required: false + schema: + type: string + responses: + "200": + description: OK + headers: + Content-Language: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.3/fail.yaml new file mode 100644 index 0000000..361c83d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.3/fail.yaml @@ -0,0 +1,23 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Create-POST does not accept an Idempotency-Key header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /orders: + post: + operationId: createOrder + summary: Create an order + description: Create an order. + tags: [orders] + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + description: Created diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.3/pass.yaml new file mode 100644 index 0000000..afc6b33 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.3/pass.yaml @@ -0,0 +1,29 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Create-POST accepts an Idempotency-Key header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /orders: + post: + operationId: createOrder + summary: Create an order + description: Create an order. + tags: [orders] + parameters: + - name: Idempotency-Key + in: header + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + responses: + "201": + description: Created diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml new file mode 100644 index 0000000..3c70ea1 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.4/fail.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Secured operation missing W3C traceparent. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + security: + - serviceAuth: [] + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml new file mode 100644 index 0000000..4009f38 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.4/pass.yaml @@ -0,0 +1,26 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Secured operation accepts W3C traceparent. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + security: + - serviceAuth: [] + parameters: + - name: traceparent + in: header + required: false + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.5/fail.yaml new file mode 100644 index 0000000..4de5fa3 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.5/fail.yaml @@ -0,0 +1,28 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Introduces a new X- prefixed header. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + parameters: + - name: X-Custom-Trace + in: header + required: false + schema: + type: string + responses: + "200": + description: OK + headers: + X-Total-Count: + schema: + type: integer diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml new file mode 100644 index 0000000..706591a --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.5/pass.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: No X- prefixed headers; W3C Trace Context is used. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + parameters: + - name: traceparent + in: header + required: false + schema: + type: string + - name: Custom-Trace + in: header + required: false + schema: + type: string + responses: + "200": + description: OK + headers: + Total-Count: + schema: + type: integer diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.6/fail.yaml new file mode 100644 index 0000000..71e3b95 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.6/fail.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Personal data addresses a citizen record in the URL. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /citizens/{email}: + get: + operationId: getCitizenByEmail + summary: Get a citizen record + description: Get a citizen record by email. + tags: [citizens] + parameters: + - name: email + in: path + required: true + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.6/pass.yaml new file mode 100644 index 0000000..b04ed81 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.6/pass.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Citizen records are addressed by an opaque server-generated ID. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /citizens/{citizenId}: + get: + operationId: getCitizen + summary: Get a citizen record + description: Get a citizen record by opaque ID. + tags: [citizens] + parameters: + - name: citizenId + in: path + required: true + schema: + type: string + responses: + "200": + description: OK diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/fail.yaml new file mode 100644 index 0000000..e64f95b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/fail.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: { title: Legacy RateLimit fail, version: 1.0.0 } +paths: + /items: + get: + responses: + "200": + description: OK + headers: + RateLimit-Limit: { schema: { type: integer } } + RateLimit-Remaining: { schema: { type: integer } } + RateLimit-Reset: { schema: { type: integer } } diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/pass.yaml new file mode 100644 index 0000000..079c325 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.7-no-legacy/pass.yaml @@ -0,0 +1,10 @@ +openapi: 3.1.0 +info: { title: Structured RateLimit pass, version: 1.0.0 } +paths: + /items: + get: + responses: + "200": + description: OK + headers: + RateLimit: { schema: { type: string } } diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.7/fail.yaml new file mode 100644 index 0000000..7a4a2bd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.7/fail.yaml @@ -0,0 +1,20 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Rate-limited endpoint missing rate-limit headers. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + responses: + "200": + description: OK + "429": + description: Too Many Requests diff --git a/api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml new file mode 100644 index 0000000..d316953 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-8.7/pass.yaml @@ -0,0 +1,31 @@ +openapi: 3.1.0 +info: + title: Sample API + version: 1.0.0 + description: Rate-limited endpoint declares rate-limit headers. + contact: + name: Sample BB Team + url: https://example.org/contact +paths: + /items: + get: + operationId: listItems + summary: List items + description: List items. + tags: [items] + responses: + "200": + description: OK + headers: + RateLimit: + schema: + type: string + "429": + description: Too Many Requests + headers: + Retry-After: + schema: + type: integer + RateLimit: + schema: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.1/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.1/fail.yaml new file mode 100644 index 0000000..867cb86 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.1/fail.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Section 9.1 fail + version: 1.0.0 +paths: + /things: + get: + operationId: listThings + responses: + '200': + description: A list of things, offered only as XML. + content: + application/xml: + schema: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.1/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.1/pass.yaml new file mode 100644 index 0000000..f4d223b --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.1/pass.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Section 9.1 pass + version: 1.0.0 +paths: + /things: + get: + operationId: listThings + responses: + '200': + description: A list of things, offered as JSON. + content: + application/json: + schema: + type: object diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml new file mode 100644 index 0000000..3c67159 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.10/fail.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: Section 9.10 fail + version: 1.0.0 +paths: + /things: + get: + operationId: listThings + x-deprecated: true + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml new file mode 100644 index 0000000..270cc92 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.10/pass.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: Section 9.10 pass + version: 1.0.0 +paths: + /things: + get: + operationId: listThings + x-govstack-deprecated: true + responses: + '200': + description: ok diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.11/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.11/fail.yaml new file mode 100644 index 0000000..6e45a5f --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.11/fail.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Section 9.11 fail + version: 1.0.0 +paths: {} +components: + securitySchemes: + oauth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: + "bb:identity:person:read": Read persons + "bb:payments:invoice:read": Read invoices diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.11/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.11/pass.yaml new file mode 100644 index 0000000..9c35ef3 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.11/pass.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Section 9.11 pass + version: 1.0.0 +paths: {} +components: + securitySchemes: + oauth: + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.org/token + scopes: + "bb:identity:person:read": Read persons + "bb:identity:invoice:read": Read invoices diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.2/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.2/fail.yaml new file mode 100644 index 0000000..c39f244 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.2/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.2 fail + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + first_name: + type: string + Last_Name: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.2/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.2/pass.yaml new file mode 100644 index 0000000..db845e0 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.2/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.2 pass + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + firstName: + type: string + lastName: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.3/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.3/fail.yaml new file mode 100644 index 0000000..02f50b5 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.3/fail.yaml @@ -0,0 +1,15 @@ +openapi: 3.1.0 +info: + title: Section 9.3 fail + version: 1.0.0 +paths: {} +components: + schemas: + Account: + type: object + properties: + isActive: + type: string + hasConsent: + type: string + enum: ["true", "false"] diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.3/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.3/pass.yaml new file mode 100644 index 0000000..e8d7071 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.3/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.3 pass + version: 1.0.0 +paths: {} +components: + schemas: + Account: + type: object + properties: + isActive: + type: boolean + hasConsent: + type: boolean diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.4/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.4/fail.yaml new file mode 100644 index 0000000..ff04a3d --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.4/fail.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + title: Section 9.4 fail + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + middleName: + type: string + nullable: true diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.4/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.4/pass.yaml new file mode 100644 index 0000000..c2b0d23 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.4/pass.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: Section 9.4 pass + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + middleName: + type: [string, "null"] diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.5/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.5/fail.yaml new file mode 100644 index 0000000..ef1b458 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.5/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.5 fail + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + "full name": + type: string + "prénom": + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.5/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.5/pass.yaml new file mode 100644 index 0000000..b55c283 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.5/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.5 pass + version: 1.0.0 +paths: {} +components: + schemas: + Person: + type: object + properties: + fullName: + type: string + firstName: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.6/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.6/fail.yaml new file mode 100644 index 0000000..4655d19 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.6/fail.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.6 fail + version: 1.0.0 +paths: {} +components: + schemas: + OrderLine: + type: object + properties: + qty: + type: integer + custAddr: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.6/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.6/pass.yaml new file mode 100644 index 0000000..1ab1087 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.6/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.6 pass + version: 1.0.0 +paths: {} +components: + schemas: + OrderLine: + type: object + properties: + quantity: + type: integer + customerAddress: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.7/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.7/fail.yaml new file mode 100644 index 0000000..7394820 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.7/fail.yaml @@ -0,0 +1,12 @@ +openapi: 3.1.0 +info: + title: Section 9.7 fail + version: 1.0.0 +paths: {} +components: + schemas: + Status: + type: string + enum: + - active + - pendingReview diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml new file mode 100644 index 0000000..70d5fb8 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.7/pass.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Section 9.7 pass + version: 1.0.0 +paths: {} +components: + schemas: + Status: + type: string + enum: + - ACTIVE + - PENDING_REVIEW + # §9.7 carve-outs: values whose form is fixed by another rule or an + # external standard keep their own casing and must not be flagged. + SigningAlgorithm: + type: string + enum: [ES256, EdDSA] + SigningCurve: + type: string + enum: [Ed25519, P-256] + MediaType: + type: string + enum: [application/json, application/problem+json] + ErrorCode: + type: string + enum: [global.govstack.identity.personNotFound] + EventType: + type: string + enum: [global.govstack.registration.record.created] + LanguageTag: + type: string + enum: [en, en-US, zh-Hans-CN] + CurrencyCode: + type: string + enum: [USD, EUR] diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.8/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.8/fail.yaml new file mode 100644 index 0000000..eb30748 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.8/fail.yaml @@ -0,0 +1,19 @@ +openapi: 3.1.0 +info: + title: Section 9.8 fail + version: 1.0.0 +paths: + /things: + get: + operationId: getThing + responses: + '200': + description: A thing whose body schema is closed at the top level. + content: + application/json: + schema: + type: object + additionalProperties: false + properties: + id: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.8/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.8/pass.yaml new file mode 100644 index 0000000..7306225 --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.8/pass.yaml @@ -0,0 +1,18 @@ +openapi: 3.1.0 +info: + title: Section 9.8 pass + version: 1.0.0 +paths: + /things: + get: + operationId: getThing + responses: + '200': + description: A thing whose body schema stays open for new fields. + content: + application/json: + schema: + type: object + properties: + id: + type: string diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.9/fail.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.9/fail.yaml new file mode 100644 index 0000000..9c01daf --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.9/fail.yaml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + title: Section 9.9 fail + version: 1.0.0 +paths: {} +components: + schemas: + Color: + type: string + enum: + - RED + - GREEN + - BLUE diff --git a/api-design-guide/linter/tests/fixtures/govstack-9.9/pass.yaml b/api-design-guide/linter/tests/fixtures/govstack-9.9/pass.yaml new file mode 100644 index 0000000..853dfdd --- /dev/null +++ b/api-design-guide/linter/tests/fixtures/govstack-9.9/pass.yaml @@ -0,0 +1,14 @@ +openapi: 3.1.0 +info: + title: Section 9.9 pass + version: 1.0.0 +paths: {} +components: + schemas: + Color: + type: string + enum: + - RED + - GREEN + - BLUE + - UNKNOWN diff --git a/api-design-guide/linter/tests/functions.test.mjs b/api-design-guide/linter/tests/functions.test.mjs new file mode 100644 index 0000000..284df8d --- /dev/null +++ b/api-design-guide/linter/tests/functions.test.mjs @@ -0,0 +1,507 @@ +// Direct unit tests of the shared custom functions. These import the ESM +// default exports and call them with crafted inputs — no Spectral runtime — so +// they pin each function's contract, cycle-safety and bad-input behaviour. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import valuePattern from '../functions/valuePattern.js'; +import schemaPropertyNames from '../functions/schemaPropertyNames.js'; +import schemaDescriptions from '../functions/schemaDescriptions.js'; +import responseHeaderRequired from '../functions/responseHeaderRequired.js'; +import operationResponses from '../functions/operationResponses.js'; +import pathSegments from '../functions/pathSegments.js'; +import envelopeShape from '../functions/envelopeShape.js'; +import securityCoverage from '../functions/securityCoverage.js'; +import schemaFieldFormat from '../functions/schemaFieldFormat.js'; +import extensionShape from '../functions/extensionShape.js'; +import mediaTypeExpected from '../functions/mediaTypeExpected.js'; +import successResponseSchema from '../functions/s07-successResponseSchema.js'; +import creationResponses from '../functions/s07-creationResponses.js'; +import baselineResponses from '../functions/s07-baselineResponses.js'; +import bbCode from '../functions/s09-bbCode.js'; +import fieldErrors from '../functions/s11-fieldErrors.js'; +import problemType from '../functions/s11-problemType.js'; +import collectionPagination from '../functions/s12-collectionPagination.js'; +import schemeExists from '../functions/s13-schemeExists.js'; +import cloudEventsPayload from '../functions/s17-cloudEventsPayload.js'; +import { walkSchema } from '../functions/lib/schemaWalk.js'; +import { isStandardUnversionedPath } from '../functions/lib/standardEndpoints.js'; + +const count = (r) => (r === undefined ? 0 : r.length); + +// Every function must tolerate junk input and return undefined, never throw. +const ALL = { + valuePattern, + schemaPropertyNames, + schemaDescriptions, + responseHeaderRequired, + operationResponses, + pathSegments, + envelopeShape, + securityCoverage, + schemaFieldFormat, + extensionShape, + mediaTypeExpected, + successResponseSchema, + creationResponses, + baselineResponses, + bbCode, + fieldErrors, + problemType, + collectionPagination, + schemeExists, + cloudEventsPayload, +}; +test('all functions return undefined on bad input, never throw', () => { + for (const [name, fn] of Object.entries(ALL)) { + for (const junk of [undefined, null, 42, 'str', [], true]) { + assert.equal(fn(junk, {}, { path: [] }), undefined, `${name}(${JSON.stringify(junk)})`); + } + } +}); + +test('valuePattern: match / notMatch / forbidPattern', () => { + assert.equal(count(valuePattern('1.2.3', { match: '^\\d+\\.\\d+\\.\\d+$' })), 0); + assert.equal(count(valuePattern('v1', { match: '^\\d+\\.\\d+\\.\\d+$' })), 1); + assert.equal(count(valuePattern('http://localhost/x', { forbidPattern: 'localhost' })), 1); + assert.equal(count(valuePattern('https://gw/x', { notMatch: 'localhost' })), 0); + assert.equal(valuePattern(123, { match: 'x' }), undefined); +}); + +test('schemaPropertyNames: casing + forbidPattern + cycle safety', () => { + const schema = { + type: 'object', + properties: { + goodName: { type: 'string' }, + Bad_Name: { type: 'string' }, + nested: { type: 'object', properties: { alsoBad: { type: 'string' }, 'has space': {} } }, + }, + }; + const camel = schemaPropertyNames(schema, { casing: 'camel' }, { path: ['x'] }); + // Bad_Name and 'has space' violate camel; nested/alsoBad is fine. + assert.equal(count(camel), 2); + assert.ok(camel[0].path[0] === 'x', 'context.path is prefixed onto finding path'); + + const spaces = schemaPropertyNames(schema, { forbidPattern: '\\s' }); + assert.equal(count(spaces), 1); // only 'has space' + + // circular schema must terminate + const cyc = { type: 'object', properties: { Bad: { type: 'string' } } }; + cyc.properties.self = cyc; + const res = schemaPropertyNames(cyc, { casing: 'camel' }); + assert.equal(count(res), 1); // 'Bad'; 'self' is fine; walk terminates +}); + +test('schemaDescriptions: properties vs all mode', () => { + const schema = { + type: 'object', + description: 'root', + properties: { a: { type: 'string', description: 'A' }, b: { type: 'string' } }, + }; + assert.equal(count(schemaDescriptions(schema, {})), 1); // b lacks description + assert.equal(count(schemaDescriptions(schema, { mode: 'all' })), 1); // b node + const noRoot = { type: 'object', properties: { a: { type: 'string', description: 'A' } } }; + assert.equal(count(schemaDescriptions(noRoot, { includeRoot: true })), 1); // root + + const assertion = { + type: 'object', + description: 'A schema that rejects a prohibited property.', + not: { required: ['prohibitedField'] }, + }; + assert.equal(count(schemaDescriptions(assertion, { mode: 'all' })), 0); +}); + +test('responseHeaderRequired: header presence + companion status', () => { + const responses = { + '201': { description: 'created' }, + '200': { description: 'ok', headers: { Location: {} } }, + }; + assert.equal(count(responseHeaderRequired(responses, { status: '201', headers: ['Location'] })), 1); + assert.equal(count(responseHeaderRequired(responses, { status: '200', headers: ['location'] })), 0); // case-insensitive + assert.equal( + count(responseHeaderRequired(responses, { status: '2xx', headers: ['ETag'], alsoRequireStatus: '304' })), + 3, // 201 + 200 lack ETag, and no 304 present + ); + assert.equal(count(responseHeaderRequired({}, { status: '201', headers: ['Location'], requireStatus: true })), 1); +}); + +test('operationResponses: require / forbid / counts', () => { + const op = { responses: { '200': {}, '404': {} } }; + assert.equal(count(operationResponses(op, { require: ['500'] })), 1); + assert.equal(count(operationResponses(op, { require: ['2xx', '4xx'] })), 0); + assert.equal(count(operationResponses({ responses: { '200': {}, '201': {} } }, { forbid: ['201'] })), 1); + assert.equal(count(operationResponses({ responses: { '200': {} } }, { minNonSuccess: 1 })), 1); + assert.equal(count(operationResponses({ responses: { '200': {} } }, { minCount: 2 })), 1); + assert.equal(count(operationResponses({}, { require: ['500'] })), 1); // no responses object +}); + +test('pathSegments: version prefix / casing / depth', () => { + const paths = { + '/v1/account-holders': {}, + '/accounts': {}, + '/v1/Bad_Segment': {}, + // 3 raw segments but only 2 NON-PARAM levels (accounts, transactions): + // `{param}` segments do not count, so this must NOT fire 5.4. + '/v1/accounts/{id}/transactions/{txId}': {}, + // 3 non-param levels (accounts, transactions, lines): must fire 5.4. + '/v1/accounts/{id}/transactions/{txId}/lines': {}, + }; + assert.equal(count(pathSegments(paths, { check: 'versionPrefix' })), 1); // /accounts + assert.equal(count(pathSegments(paths, { check: 'versionPrefix', exemptPaths: ['/accounts'] })), 0); // 5.9 exemption + assert.equal(count(pathSegments(paths, { check: 'segmentCasing', casing: 'kebab' })), 1); // Bad_Segment + assert.equal(count(pathSegments(paths, { check: 'maxDepthAfterVersion', max: 2 })), 1); // only the 3-non-param-level path +}); + +test('isStandardUnversionedPath: the 5.10 closed set', () => { + for (const p of ['/health', '/ready', '/openapi.json', '/asyncapi.json', '/.well-known/oauth-protected-resource']) { + assert.equal(isStandardUnversionedPath(p), true, p); + } + for (const p of ['/v1/health', '/healthz', '/well-known/jwks.json', '/', '', 42, undefined]) { + assert.equal(isStandardUnversionedPath(p), false, String(p)); + } +}); + +test('pathSegments: standard unversioned endpoints are exempt (5.10) and read-only', () => { + const paths = { + '/health': { get: {}, delete: {} }, + '/.well-known/oauth-protected-resource': { get: {}, post: {} }, + '/v1/things': { get: {}, post: {} }, + }; + // The 5.10 set sits outside /v{N}/ by design, and `.well-known` is not kebab. + assert.equal(count(pathSegments(paths, { check: 'versionPrefix' })), 0); + assert.equal(count(pathSegments(paths, { check: 'segmentCasing', casing: 'kebab' })), 0); + // They are read-only: a mutating method there is a business resource in hiding. + const readOnly = pathSegments(paths, { check: 'standardEndpointsReadOnly' }); + assert.deepEqual(readOnly.map((r) => r.path.join(' ')).sort(), [ + '/.well-known/oauth-protected-resource post', + '/health delete', + ]); +}); + +test('s12-collectionPagination: 5.10 and maxItems carve-outs, pageParam, pageSizeBounds', () => { + const ctx = (pathKey) => ({ path: ['paths', pathKey, 'get'] }); + const withSchema = (schema, parameters) => ({ + ...(parameters ? { parameters } : {}), + responses: { 200: { content: { 'application/json': { schema } } } }, + }); + const unbounded = withSchema({ type: 'object', properties: { items: { type: 'array' } } }); + + // An unpaginated, unbounded collection fires both 12.1 and 12.4... + assert.equal(count(collectionPagination(unbounded, { mode: 'pageParam' }, ctx('/v1/things'))), 1); + assert.equal(count(collectionPagination(unbounded, { mode: 'pageSizeBounds' }, ctx('/v1/things'))), 1); + // ...unless the path is a standard unversioned endpoint, which is not a collection... + assert.equal(count(collectionPagination(unbounded, { mode: 'pageParam' }, ctx('/health'))), 0); + assert.equal(count(collectionPagination(unbounded, { mode: 'pageSizeBounds' }, ctx('/health'))), 0); + // ...or the response bounds every array it returns with maxItems (12.1). + const bounded = withSchema({ type: 'object', properties: { items: { type: 'array', maxItems: 40 } } }); + assert.equal(count(collectionPagination(bounded, { mode: 'pageParam' }, ctx('/v1/locales'))), 0); + // One unbounded array is enough to make the whole response unbounded. + const halfBounded = withSchema({ + type: 'object', + properties: { items: { type: 'array', maxItems: 40 }, extras: { type: 'array' } }, + }); + assert.equal(count(collectionPagination(halfBounded, { mode: 'pageParam' }, ctx('/v1/locales'))), 1); + + // 12.1 is satisfied by either cursor or offset pagination (12.6). + const offset = withSchema({ type: 'object' }, [{ name: 'offset' }, { name: 'limit' }]); + assert.equal(count(collectionPagination(offset, { mode: 'pageParam' }, ctx('/v1/things'))), 0); + // 12.4 wants both bounds on pageSize, not just its presence. + const noMaximum = withSchema({ type: 'object' }, [{ name: 'pageSize', schema: { default: 20 } }]); + assert.equal(count(collectionPagination(noMaximum, { mode: 'pageSizeBounds' }, ctx('/v1/things'))), 1); + const bothBounds = withSchema({ type: 'object' }, [{ name: 'pageSize', schema: { default: 20, maximum: 100 } }]); + assert.equal(count(collectionPagination(bothBounds, { mode: 'pageSizeBounds' }, ctx('/v1/things'))), 0); + + const cursorParams = [{ name: 'pageSize' }, { name: 'cursor' }]; + const cursorEnvelope = withSchema({ + type: 'object', + required: ['items', 'pageInfo'], + properties: { + items: { type: 'array' }, + pageInfo: { + type: 'object', + required: ['nextCursor'], + properties: { + nextCursor: { type: ['string', 'null'], minLength: 1 }, + }, + }, + }, + }, cursorParams); + assert.equal(count(collectionPagination(cursorEnvelope, { mode: 'cursorEnvelope' }, ctx('/v1/things'))), 0); + + const nonNullable = structuredClone(cursorEnvelope); + nonNullable.responses[200].content['application/json'].schema.properties.pageInfo.properties.nextCursor = { + type: 'string', + minLength: 1, + }; + assert.equal(count(collectionPagination(nonNullable, { mode: 'cursorEnvelope' }, ctx('/v1/things'))), 1); + + const permitsIntegerCursor = structuredClone(cursorEnvelope); + permitsIntegerCursor.responses[200].content['application/json'].schema.properties.pageInfo.properties.nextCursor = { + type: ['string', 'null', 'integer'], + minLength: 1, + }; + assert.equal(count(collectionPagination(permitsIntegerCursor, { mode: 'cursorEnvelope' }, ctx('/v1/things'))), 1); + + const permitsIntegerBranch = structuredClone(cursorEnvelope); + permitsIntegerBranch.responses[200].content['application/json'].schema.properties.pageInfo.properties.nextCursor = { + anyOf: [ + { type: 'string', minLength: 1 }, + { type: 'null' }, + { type: 'integer' }, + ], + }; + assert.equal(count(collectionPagination(permitsIntegerBranch, { mode: 'cursorEnvelope' }, ctx('/v1/things'))), 1); + + const declaresHasMore = structuredClone(cursorEnvelope); + declaresHasMore.responses[200].content['application/json'].schema.properties.pageInfo.properties.hasMore = { + type: 'boolean', + }; + assert.equal(count(collectionPagination(declaresHasMore, { mode: 'cursorEnvelope' }, ctx('/v1/things'))), 1); +}); + +test('s09-bbCode: canonical problem-type URIs participate in the single-code check', () => { + const consistent = { + security: [{ oauth: ['bb:registry:records:read'] }], + example: { type: 'https://govstack.global/problems/registry/record-not-found' }, + }; + assert.equal(count(bbCode(consistent)), 0); + + const inconsistent = structuredClone(consistent); + inconsistent.example.type = 'https://govstack.global/problems/payments/record-not-found'; + assert.equal(count(bbCode(inconsistent)), 1); + + const malformed = { + example: { type: 'https://govstack.global/problems/Registry/record-not-found' }, + }; + assert.equal(count(bbCode(malformed)), 1); +}); + +test('s11-fieldErrors: non-field problems are ignored and opted-in errors require pointer/message', () => { + const ordinaryProblem = { + type: 'object', + required: ['type', 'title', 'status', 'traceId'], + properties: { type: {}, title: {}, status: {}, traceId: {} }, + }; + assert.equal(fieldErrors(ordinaryProblem), undefined); + + const valid = { + allOf: [ + ordinaryProblem, + { + required: ['errors'], + properties: { + errors: { + type: 'array', + items: { + required: ['pointer', 'message'], + properties: { pointer: { type: 'string' }, message: { type: 'string' } }, + }, + }, + }, + }, + ], + }; + assert.equal(count(fieldErrors(valid)), 0); + + const invalid = structuredClone(valid); + invalid.allOf[1].properties.errors.items.required = ['pointer']; + delete invalid.allOf[1].properties.errors.items.properties.message; + assert.equal(count(fieldErrors(invalid)), 2); +}); + +test('s11-problemType: validates literal media-type examples only when present', () => { + const good = { + example: { + type: 'https://govstack.global/problems/registry/record-not-found', + title: 'Not found', + }, + }; + assert.equal(count(problemType(good)), 0); + good.example.type = 'https://docs.example.gov/problems/not-found'; + assert.equal(count(problemType(good)), 1); + assert.equal(problemType({ schema: { type: 'object' } }), undefined); +}); + +test('s13-schemeExists: types / oauthFlows / httpBearerFormats', () => { + const doc = (schemes) => ({ components: { securitySchemes: schemes } }); + const citizen = { types: ['openIdConnect', 'oauth2'], httpBearerFormats: ['JWT'] }; + assert.equal(count(schemeExists(doc({ a: { type: 'openIdConnect' } }), citizen)), 0); + // A resource server that only validates tokens issued elsewhere declares + // http bearer + JWT rather than misdescribing itself with an oauth2 flow. + assert.equal(count(schemeExists(doc({ a: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } }), citizen)), 0); + // An opaque bearer token or basic auth is not that profile. + assert.equal(count(schemeExists(doc({ a: { type: 'http', scheme: 'bearer' } }), citizen)), 1); + assert.equal(count(schemeExists(doc({ a: { type: 'http', scheme: 'basic' } }), citizen)), 1); + + const service = { types: ['mutualTLS'], oauthFlows: ['clientCredentials'] }; + assert.equal(count(schemeExists(doc({ a: { type: 'mutualTLS' } }), service)), 0); + assert.equal(count(schemeExists(doc({ a: { type: 'oauth2', flows: { clientCredentials: {} } } }), service)), 0); + assert.equal(count(schemeExists(doc({ a: { type: 'oauth2', flows: { authorizationCode: {} } } }), service)), 1); + assert.equal(count(schemeExists(doc({}), service)), 1); +}); + +test('envelopeShape: required / nested / const / enum / allOf', () => { + const page = { + type: 'object', + required: ['items', 'pageInfo'], + properties: { + items: { type: 'array' }, + pageInfo: { type: 'object', required: ['nextCursor'], properties: { nextCursor: {} } }, + }, + }; + assert.equal( + count(envelopeShape(page, { requiredProperties: ['items', 'pageInfo'], properties: { pageInfo: { requiredProperties: ['nextCursor'] } } })), + 0, + ); + const bad = { type: 'object', required: ['items'], properties: { items: { type: 'array' } } }; + assert.ok(count(envelopeShape(bad, { requiredProperties: ['items', 'pageInfo'] })) >= 1); + assert.equal(count(envelopeShape(bad, { forbiddenProperties: ['items'] })), 1); + + const event = { type: 'object', properties: { specversion: { const: '1.0' }, type: { const: 'x' } } }; + assert.equal(count(envelopeShape(event, { properties: { specversion: { const: '1.0' } } })), 0); + assert.equal(count(envelopeShape(event, { properties: { specversion: { const: '2.0' } } })), 1); + + const status = { type: 'string', enum: ['PENDING', 'DONE'] }; + assert.equal(count(envelopeShape(status, { enum: ['PENDING', 'DONE'] })), 0); + assert.equal(count(envelopeShape(status, { enum: ['PENDING', 'DONE', 'FAILED'] })), 1); + + const composed = { allOf: [{ required: ['a'], properties: { a: {} } }, { required: ['b'], properties: { b: {} } }] }; + assert.equal(count(envelopeShape(composed, { requiredProperties: ['a', 'b'] })), 0); +}); + +test('securityCoverage: covered vs none mode', () => { + const doc = { + security: [{ oauth: [] }], + components: { securitySchemes: { oauth: {} } }, + paths: { '/v1/x': { get: {}, post: { security: [] } } }, + }; + assert.equal(count(securityCoverage(doc, {})), 0); // both covered (root + explicit override) + + const uncovered = { paths: { '/v1/x': { get: {} } } }; + assert.equal(count(securityCoverage(uncovered, {})), 1); + assert.equal(count(securityCoverage(uncovered, { requireSchemes: true })), 2); // + missing schemes + + assert.equal(count(securityCoverage({ security: [{ a: [] }] }, { mode: 'none' })), 1); + assert.equal(count(securityCoverage({ security: [] }, { mode: 'none' })), 0); +}); + +test('schemaFieldFormat: format / forbidType / mustDeclare', () => { + const schema = { + type: 'object', + properties: { + createdAt: { type: 'string' }, + updatedAt: { type: 'string', format: 'date-time' }, + amount: { type: 'number' }, + photo: { type: 'string' }, + }, + }; + assert.equal(count(schemaFieldFormat(schema, { namePattern: 'At$', require: { format: 'date-time' } })), 1); // createdAt + assert.equal(count(schemaFieldFormat(schema, { namePattern: '^amount$', require: { forbidType: 'number' } })), 1); + assert.equal( + count(schemaFieldFormat(schema, { namePattern: '^photo$', require: { contentEncoding: 'base64', mustDeclare: ['maxLength'] } })), + 2, + ); +}); + +test('extensionShape: presence / enum / object shape / semver', () => { + assert.equal(count(extensionShape({}, { extension: 'x-example-mode' })), 1); // required, absent + assert.equal(count(extensionShape({ 'x-example-mode': 'active' }, { extension: 'x-example-mode', enum: ['active', 'inactive'] })), 0); + assert.equal(count(extensionShape({ 'x-example-mode': 'unknown' }, { extension: 'x-example-mode', enum: ['active', 'inactive'] })), 1); + + const good = { 'x-govstack-api-guide': { version: '0.2.0' } }; + assert.equal(count(extensionShape(good, { extension: 'x-govstack-api-guide', valueType: 'object', requiredKeys: ['version'], semverKeys: ['version'] })), 0); + const bad = { 'x-govstack-api-guide': { version: 'draft' } }; + assert.equal(count(extensionShape(bad, { extension: 'x-govstack-api-guide', valueType: 'object', requiredKeys: ['version'], semverKeys: ['version'] })), 1); +}); + +test('mediaTypeExpected: require / requireOneOf / forbid', () => { + const content = { 'application/json': {}, 'application/problem+json': {} }; + assert.equal(count(mediaTypeExpected(content, { require: ['application/problem\\+json'] })), 0); + assert.equal(count(mediaTypeExpected(content, { require: ['application/merge-patch\\+json'] })), 1); + assert.equal(count(mediaTypeExpected(content, { requireOneOf: ['application/merge-patch\\+json', 'application/json'] })), 0); + assert.equal(count(mediaTypeExpected({ 'text/plain': {} }, { forbid: ['text/plain'] })), 1); +}); + +test('successResponseSchema: declared success content requires schemas', () => { + const bad = { + paths: { + '/v1/things': { + get: { responses: { 200: { content: { 'application/json': {} } }, 204: {} } }, + }, + }, + }; + assert.equal(count(successResponseSchema(bad)), 1); + bad.paths['/v1/things'].get.responses[200].content['application/json'].schema = { type: 'object' }; + assert.equal(count(successResponseSchema(bad)), 0); +}); + +test('creationResponses and baselineResponses use visible operation shape', () => { + const doc = { + security: [{ oauth: [] }], + paths: { + '/v1/things/{thingId}': { + post: { + operationId: 'createThing', + requestBody: { content: {} }, + responses: { 200: {} }, + }, + }, + }, + }; + assert.equal(count(creationResponses(doc)), 1); + assert.equal(count(baselineResponses(doc)), 3); + doc.paths['/v1/things/{thingId}'].post.responses = { 201: {}, 400: {}, 401: {}, 404: {} }; + assert.equal(count(creationResponses(doc)), 0); + assert.equal(count(baselineResponses(doc)), 0); +}); + +test('walkSchema: visits combinators and is cycle-safe', () => { + const seen = []; + const schema = { + type: 'object', + properties: { a: { type: 'string' } }, + allOf: [{ type: 'object', properties: { b: {} } }], + items: { type: 'number' }, + }; + walkSchema(schema, (_node, path) => seen.push(path.join('/'))); + assert.ok(seen.includes('')); // root + assert.ok(seen.includes('properties/a')); + assert.ok(seen.includes('allOf/0')); + assert.ok(seen.includes('items')); + + const cyc = { type: 'object' }; + cyc.items = cyc; + let visits = 0; + walkSchema(cyc, () => (visits += 1)); + assert.equal(visits, 1); // visited once, no infinite loop +}); + +test('s17-cloudEventsPayload: scopes non-CloudEvents and requires a local vendored ref', () => { + const localJson = { + contentType: 'application/json', + payload: { type: 'object', properties: { command: { type: 'string' } } }, + }; + assert.equal(cloudEventsPayload(localJson, {}, { path: [] }), undefined); + + const localEnvelope = { + contentType: 'application/cloudevents+json', + payload: { + allOf: [ + { $ref: '../../../../api/common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' }, + ], + }, + }; + assert.equal( + cloudEventsPayload(localEnvelope, { requireSharedReference: true }, { path: [] }), + undefined, + ); + + const remoteEnvelope = structuredClone(localEnvelope); + remoteEnvelope.payload.allOf[0].$ref = + 'https://example.org/api/common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope'; + assert.equal( + count(cloudEventsPayload(remoteEnvelope, { requireSharedReference: true }, { path: [] })), + 1, + ); +}); diff --git a/api-design-guide/linter/tests/golden.test.mjs b/api-design-guide/linter/tests/golden.test.mjs new file mode 100644 index 0000000..5ffafce --- /dev/null +++ b/api-design-guide/linter/tests/golden.test.mjs @@ -0,0 +1,59 @@ +// Golden reference specs — conformance test. +// +// Lints the two GOLDEN reference documents (tests/golden/*.yaml) with the DEFAULT +// bundled ruleset (ruleset.yaml, all formats). The goldens are hand-built to +// satisfy every default check simultaneously and double as reference examples for +// BB spec editors. +// +// BOTH goldens MUST lint to ZERO findings of any severity under the default +// ruleset. The ruleset exempts the guide §5.10 standard unversioned endpoints +// (/health, /ready, /.well-known/*, spec discovery) from the resource-oriented +// rules that do not apply to them (§5.1 version prefix, §5.3 segment casing, +// §12.x pagination, §7.16 ETag), scopes the §5.9 application/json media type to +// the SUCCESS response so the §11.1 problem+json error response is satisfiable, +// and counts only non-param path segments toward §5.4 max nesting depth so the +// §15.5/§16.11 mandated action sub-resource paths stay in bounds. Any finding is +// therefore a genuine regression; the test prints the full finding list on +// failure for debugging. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { makeSpectral, lintFile, RULESET_PATH } from './_lint-helper.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const GOLDEN = join(HERE, 'golden'); +const OPENAPI_GOLDEN = resolve(GOLDEN, 'openapi-golden.yaml'); +const ASYNCAPI_GOLDEN = resolve(GOLDEN, 'asyncapi-golden.yaml'); + +// The DEFAULT ruleset (not the strict profile), matching CI gating. +const spectral = await makeSpectral(RULESET_PATH); + +/** A stable, sorted, human-readable dump of findings for assertion messages. */ +const dump = (findings) => + JSON.stringify( + findings + .map((f) => ({ code: f.code, severity: f.severity, path: f.path.join('/'), message: f.message })) + .sort((a, b) => (a.code + a.path).localeCompare(b.code + b.path)), + null, + 2, + ); + +test('asyncapi-golden.yaml lints clean under the default ruleset (zero findings)', async () => { + const findings = await lintFile(spectral, ASYNCAPI_GOLDEN); + assert.equal( + findings.length, + 0, + `Expected zero findings for the AsyncAPI golden, got ${findings.length}:\n${dump(findings)}`, + ); +}); + +test('openapi-golden.yaml lints clean under the default ruleset (zero findings)', async () => { + const findings = await lintFile(spectral, OPENAPI_GOLDEN); + assert.equal( + findings.length, + 0, + `Expected zero findings for the OpenAPI golden, got ${findings.length}:\n${dump(findings)}`, + ); +}); diff --git a/api-design-guide/linter/tests/golden/asyncapi-golden.yaml b/api-design-guide/linter/tests/golden/asyncapi-golden.yaml new file mode 100644 index 0000000..4cdde6c --- /dev/null +++ b/api-design-guide/linter/tests/golden/asyncapi-golden.yaml @@ -0,0 +1,338 @@ +# GovStack API Design Guide — GOLDEN AsyncAPI reference spec +# ========================================================== +# A small, realistic AsyncAPI 3.0.0 spec for the same fictional GovStack Building +# Block (bb-code: "registry"). It is written to satisfy EVERY check in the default +# bundled ruleset simultaneously and to double as a copy-able reference for BB +# spec editors. +# +# It exercises the guide's event-driven shapes: reverse-DNS logical channel IDs +# with the major version (§17.2), native addresses, and declared+described +# parameters (§17.4), operations +# with send/receive perspective and complete metadata (§3.7/§17.1), CloudEvents +# JSON payloads (§17.6) with camelCase properties (§3.9) and camelCase headers +# (§17.8), message examples (§17.20), security covering every operation via the +# server (§17.10), shared external envelope and error schemas (§17.7), an async +# rejection message using the §11 error envelope with correlation (§17.16), a +# request-reply pair with a reply channel and correlationId (§17.17), protocol +# bindings matching the server protocol (§17.19), and the §20.3 conformance +# declaration. +# +# Unlike the OpenAPI golden, this file lints to ZERO findings: AsyncAPI has no +# unversioned /health analogue, so none of the rule-scoping contradictions apply. +asyncapi: 3.0.0 +info: + title: GovStack Registry Building Block Events + version: 1.0.0 + description: >- + Event-driven surface of the fictional GovStack Registry Building Block: + publishes registrant lifecycle events and consumes registrant commands over + Kafka. This document is a conformance reference for the GovStack Cross-BB API + Design Guide. + contact: + name: GovStack Registry BB Maintainers + url: https://example.gov/registry/support + email: registry-api@example.gov + license: + name: CC-BY-4.0 + url: https://creativecommons.org/licenses/by/4.0/ + x-govstack-api-guide: + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft +servers: + production: + host: kafka.example.gov:9092 + protocol: kafka + description: Production Kafka broker for the Registry Building Block. + security: + - $ref: '#/components/securitySchemes/registryOAuth' +defaultContentType: application/cloudevents+json +channels: + global.govstack.registry.v1.registrant.registered: + address: registry.registrant.registered + description: Registrant lifecycle events published by the Registry BB. + servers: + - $ref: '#/servers/production' + messages: + registered: + $ref: '#/components/messages/registrantRegisteredEvent' + bindings: + kafka: + bindingVersion: '0.5.0' + global.govstack.registry.v1.registrant.commands: + address: registry.registrant.{registrantId}.commands + description: Commands directed at a specific registrant, consumed by the Registry BB. + parameters: + registrantId: + $ref: '#/components/parameters/registrantId' + servers: + - $ref: '#/servers/production' + messages: + deregister: + $ref: '#/components/messages/deregisterRegistrantCommand' + bindings: + kafka: + bindingVersion: '0.5.0' + global.govstack.registry.v1.registrant.command-replies: + address: registry.registrant.command-replies + description: Replies and rejections for registrant commands. + servers: + - $ref: '#/servers/production' + messages: + result: + $ref: '#/components/messages/deregisterRegistrantResult' + rejected: + $ref: '#/components/messages/registrantDeregistrationRejected' + bindings: + kafka: + bindingVersion: '0.5.0' +operations: + sendRegistrantRegistered: + action: send + summary: Publish a registrant-registered event + description: >- + The Registry BB publishes a CloudEvents envelope whenever a registrant is + registered. The broker may redeliver an event; consumers de-duplicate on + the CloudEvents source and id pair. No ordering guarantee is promised. + tags: + - name: Registrants + channel: + $ref: '#/channels/global.govstack.registry.v1.registrant.registered' + messages: + - $ref: '#/channels/global.govstack.registry.v1.registrant.registered/messages/registered' + receiveDeregisterCommand: + action: receive + summary: Consume a deregister-registrant command + description: >- + The Registry BB consumes a deregister command for a registrant and replies + on the command-replies channel. The broker may redeliver the command; the + command is idempotent on its CloudEvents idempotencykey attribute. This is + a request-reply operation correlated by correlationId. + tags: + - name: Registrants + channel: + $ref: '#/channels/global.govstack.registry.v1.registrant.commands' + messages: + - $ref: '#/channels/global.govstack.registry.v1.registrant.commands/messages/deregister' + reply: + channel: + $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies' + messages: + - $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies/messages/result' + sendDeregistrationRejected: + action: send + summary: Publish a deregistration-rejected error + description: >- + The Registry BB publishes a rejection using the §11 error envelope when a + deregister command cannot be honoured. Correlated to the originating command + by correlationId. + tags: + - name: Registrants + channel: + $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies' + messages: + - $ref: '#/channels/global.govstack.registry.v1.registrant.command-replies/messages/rejected' +components: + securitySchemes: + registryOAuth: + type: oauth2 + description: OAuth 2.0 client-credentials for service-to-service event access. + flows: + clientCredentials: + tokenUrl: https://id.example.gov/oauth2/token + availableScopes: + bb:registry:events:subscribe: Consume registrant lifecycle events. + bb:registry:events:publish: Publish registrant lifecycle events. + parameters: + registrantId: + description: Opaque, server-generated identifier of the registrant the channel routes on. + messages: + registrantRegisteredEvent: + name: registrantRegisteredEvent + title: Registrant registered + summary: Emitted when a registrant is registered. + contentType: application/cloudevents+json + payload: + allOf: + - $ref: '../../../../api/common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + description: Registry-specific event type and domain payload. + required: [data] + properties: + type: + description: Stable semantic event type. + const: global.govstack.registry.registrant.registered + data: + type: object + description: GovStack domain payload for the registrant-registered event. + required: [registrantId, registrationStatus, occurredAt] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant that was registered. + registrationStatus: + type: string + description: Registration status at the time of the event. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + occurredAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registration occurred. + examples: + - name: registrantRegistered + summary: A newly registered registrant. + payload: + specversion: '1.0' + id: 0b8c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e + source: /govstack/registry + type: global.govstack.registry.registrant.registered + time: '2026-07-10T12:34:56Z' + datacontenttype: application/json + subject: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + data: + registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + registrationStatus: ACTIVE + occurredAt: '2026-07-10T12:34:56Z' + deregisterRegistrantCommand: + name: deregisterRegistrantCommand + title: Deregister registrant command + summary: Requests deregistration of a registrant. + contentType: application/cloudevents+json + headers: + type: object + description: Message metadata headers for the deregister command. + properties: + correlationId: + type: string + format: uuid + description: Correlation identifier echoed on the reply. + correlationId: + location: $message.header#/correlationId + description: Correlates the command with its reply on the command-replies channel. + payload: + allOf: + - $ref: '../../../../api/common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + description: Registry-specific command type and domain payload. + required: [idempotencykey, data] + properties: + type: + description: Stable semantic command type. + const: global.govstack.registry.registrant.deregisterRequested + idempotencykey: + type: string + format: uuid + description: CloudEvents extension used to de-duplicate command processing. + data: + type: object + description: GovStack domain payload for the deregister command. + required: [registrantId, reason] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant to deregister. + reason: + type: string + description: Human-readable reason for the deregistration. + examples: + - name: deregisterRegistrant + summary: A deregister command for a registrant. + headers: + correlationId: 7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d + payload: + specversion: '1.0' + id: 2c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f + source: /govstack/registry + type: global.govstack.registry.registrant.deregisterRequested + time: '2026-07-10T12:40:00Z' + datacontenttype: application/json + idempotencykey: 2c3d4e5f-6a7b-8c9d-0e1f-2a3b4c5d6e7f + data: + registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + reason: Requested by registrant. + deregisterRegistrantResult: + name: deregisterRegistrantResult + title: Deregister registrant result + summary: Reports the outcome of a deregister command. + contentType: application/cloudevents+json + headers: + type: object + description: Message metadata headers for the deregister result. + properties: + correlationId: + type: string + format: uuid + description: Correlation identifier copied from the originating command. + correlationId: + location: $message.header#/correlationId + description: Correlates the reply back to the originating command. + payload: + allOf: + - $ref: '../../../../api/common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + description: Registry-specific result type and domain payload. + required: [data] + properties: + type: + description: Stable semantic result type. + const: global.govstack.registry.registrant.deregistered + data: + type: object + description: GovStack domain payload for the deregister result. + required: [registrantId, deregisteredAt] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant that was deregistered. + deregisteredAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registrant was deregistered. + examples: + - name: deregisterResult + summary: A successful deregistration result. + headers: + correlationId: 7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d + payload: + specversion: '1.0' + id: 3d4e5f6a-7b8c-9d0e-1f2a-3b4c5d6e7f80 + source: /govstack/registry + type: global.govstack.registry.registrant.deregistered + time: '2026-07-10T12:41:00Z' + datacontenttype: application/json + data: + registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + deregisteredAt: '2026-07-10T12:41:00Z' + registrantDeregistrationRejected: + name: registrantDeregistrationRejected + title: Registrant deregistration rejected + summary: Reports that a deregister command was rejected. + contentType: application/json + headers: + type: object + description: Message metadata headers for the rejection. + properties: + correlationId: + type: string + format: uuid + description: Correlation identifier copied from the originating command. + correlationId: + location: $message.header#/correlationId + description: Correlates the rejection back to the originating command. + payload: + $ref: '../../../../api/common/govstack-asyncapi-common.yaml#/components/schemas/GovStackAsyncError' + examples: + - name: deregistrationRejected + summary: A rejected deregistration. + headers: + correlationId: 7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d + payload: + type: https://docs.example.gov/registry/problems/deregistration-rejected + title: Deregistration rejected + detail: The registrant has an active obligation and cannot be deregistered. + code: global.govstack.registry.deregistrationRejected + traceId: 9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c + timestamp: '2026-07-10T12:41:05Z' diff --git a/api-design-guide/linter/tests/golden/openapi-golden.yaml b/api-design-guide/linter/tests/golden/openapi-golden.yaml new file mode 100644 index 0000000..60c19b2 --- /dev/null +++ b/api-design-guide/linter/tests/golden/openapi-golden.yaml @@ -0,0 +1,1371 @@ +# GovStack API Design Guide — GOLDEN OpenAPI reference spec +# ========================================================= +# A small, realistic OpenAPI 3.1.0 spec for a fictional GovStack Building Block +# (bb-code: "registry", a civil-registration registry). It is written to satisfy +# EVERY check in the default bundled ruleset simultaneously and to double as a +# copy-able reference for BB spec editors. +# +# It deliberately exercises the guide's shapes: /v1 kebab-case paths, camelCase +# params/properties, described schemas (§4.1) with examples (§4.2), the §5.9 +# health endpoint family, local error Response Objects/examples built on the +# external RFC 9457 Problem and ValidationProblem schemas (§11), cursor pagination +# (§12), a creating POST with Idempotency-Key + Location (§8.3/§14.1), traceparent +# Trace Context correlation (§8.4), ETag/If-Match optimistic concurrency (§7.16/§7.17), merge- +# patch PATCH (§6.4), async long-running Operations (§15), CloudEvents webhooks + +# subscription control plane (§16), OAuth2/OIDC/mTLS security with namespaced +# scopes (§13), and the §20.3 conformance declaration. +# +# This spec lints to ZERO findings under the default ruleset. The ruleset +# exempts the §5.10 standard unversioned endpoints (here, /health) from the +# resource-oriented rules that do not apply to them (§5.1 version prefix, §5.3 +# segment casing, §12.x pagination, §7.16 ETag), scopes the §5.9 +# application/json media type to the SUCCESS (200) response so the §11.1 +# problem+json error responses are satisfiable, and counts only non-param +# segments toward §5.4 max nesting depth so the §15.5 action sub-resource path +# (/v1/operations/{operationId}/cancel) stays within two levels. +openapi: 3.1.0 +info: + title: GovStack Registry Building Block API + version: 1.0.0 + description: >- + Synchronous REST surface of the fictional GovStack Registry Building Block: + manage registrants, run long-running bulk imports as asynchronous + Operations, and manage webhook subscriptions for registrant lifecycle + events. This document is a conformance reference for the GovStack Cross-BB + API Design Guide. + contact: + name: GovStack Registry BB Maintainers + url: https://example.gov/registry/support + email: registry-api@example.gov + license: + name: CC-BY-4.0 + url: https://creativecommons.org/licenses/by/4.0/ + x-govstack-api-guide: + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft +servers: + - url: https://api.example.gov/registry + description: Production gateway for the Registry Building Block. + - url: https://sandbox.api.example.gov/registry + description: Sandbox environment for integration testing. +security: + - registryOAuth: + - bb:registry:registrant:read +tags: + - name: Registrants + description: Create, read, update and delete registrant records. + - name: Operations + description: Poll and cancel long-running asynchronous Operations. + - name: Subscriptions + description: Manage webhook subscriptions for registrant lifecycle events. + - name: Health + description: Operational liveness endpoint. +paths: + /health: + get: + operationId: getHealth + summary: Liveness check + description: >- + Unversioned operational liveness endpoint per guide §5.9. Health is carried + by the status code: 200 when the service can accept work, 503 when it + temporarily cannot. Unauthenticated and free of citizen data and + system-internal detail. + tags: [Health] + security: [] + parameters: + - $ref: '#/components/parameters/Traceparent' + responses: + '200': + description: The service is alive and able to accept work. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + content: + application/json: + schema: + $ref: '#/components/schemas/HealthStatus' + # §5.9 requires the unhealthy status code as well as the healthy one, and + # §11.1 makes every 4xx/5xx application/problem+json, so the 503 carries + # the standard problem envelope (Cache-Control: no-store included). The + # §5.9 application/json media type applies to the SUCCESS (200) payload + # above, not to error responses. + '503': + $ref: '#/components/responses/ServiceUnavailable' + # §7.13 requires a documented 500 on every operation. + '500': + $ref: '#/components/responses/ServerError' + /v1/registrants: + get: + operationId: listRegistrants + summary: List registrants + description: >- + Returns a cursor-paginated page of registrant records, most recently + updated first. Supports simple equality filtering and sorting. + tags: [Registrants] + parameters: + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/Cursor' + - $ref: '#/components/parameters/Sort' + - $ref: '#/components/parameters/FilterRegistrationStatus' + - $ref: '#/components/parameters/FilterCountryCode' + responses: + '200': + description: A page of registrants. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrantPage' + '304': + $ref: '#/components/responses/NotModified' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/ServerError' + post: + operationId: createRegistrant + summary: Create a registrant + description: >- + Registers a new registrant and returns the created record. Non-idempotent + create: clients must supply an Idempotency-Key so retries are safe. + tags: [Registrants] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + description: Attributes for the registrant to create. + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrantCreate' + responses: + '201': + description: The registrant was created. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + Location: + $ref: '#/components/headers/Location' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Registrant' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '409': + $ref: '#/components/responses/Conflict' + '422': + $ref: '#/components/responses/ValidationFailed' + '500': + $ref: '#/components/responses/ServerError' + /v1/registrants/{registrantId}: + parameters: + - $ref: '#/components/parameters/RegistrantId' + get: + operationId: getRegistrant + summary: Fetch a registrant + description: Returns a single registrant record by its opaque identifier. + tags: [Registrants] + parameters: + - $ref: '#/components/parameters/Traceparent' + responses: + '200': + description: The registrant record. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Registrant' + '304': + $ref: '#/components/responses/NotModified' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + put: + operationId: replaceRegistrant + summary: Replace a registrant + description: >- + Fully replaces a registrant record. Uses optimistic concurrency: the + caller must echo the current ETag via If-Match. + tags: [Registrants] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + description: The full replacement representation of the registrant. + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrantCreate' + responses: + '200': + description: The registrant was replaced. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Registrant' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '412': + $ref: '#/components/responses/PreconditionFailed' + '500': + $ref: '#/components/responses/ServerError' + patch: + operationId: updateRegistrant + summary: Partially update a registrant + description: >- + Applies a JSON Merge Patch (RFC 7396) to a registrant record. Uses + optimistic concurrency via If-Match. + tags: [Registrants] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/IfMatch' + requestBody: + required: true + description: The merge-patch document describing the fields to change. + content: + application/merge-patch+json: + schema: + $ref: '#/components/schemas/RegistrantPatch' + responses: + '200': + description: The registrant was updated. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Registrant' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '412': + $ref: '#/components/responses/PreconditionFailed' + '415': + $ref: '#/components/responses/UnsupportedMediaType' + '500': + $ref: '#/components/responses/ServerError' + delete: + operationId: deleteRegistrant + summary: Delete a registrant + description: Permanently removes a registrant record. + tags: [Registrants] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/Traceparent' + responses: + '204': + description: The registrant was deleted; no body is returned. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + /v1/registrants/search: + post: + operationId: searchRegistrants + summary: Search registrants + description: >- + Complex, read-only registrant query. Returns 200 (never 201) and uses the + cursor pagination envelope; filter criteria and pagination travel in the + request body per guide §6.6/§12.9. + tags: [Registrants] + parameters: + - $ref: '#/components/parameters/Traceparent' + requestBody: + required: true + description: The search filter plus cursor pagination controls. + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrantSearchRequest' + responses: + '200': + description: A page of matching registrants. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + content: + application/json: + schema: + $ref: '#/components/schemas/RegistrantPage' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/ServerError' + /v1/bulk-imports: + post: + operationId: startBulkImport + summary: Start a bulk import + description: >- + Starts a long-running bulk import of registrants from an external source. + The work cannot complete synchronously, so the endpoint returns 202 with a + Location pointing at the Operation resource to poll. + tags: [Operations] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + description: Location of the source data to import. + content: + application/json: + schema: + $ref: '#/components/schemas/BulkImportRequest' + responses: + '202': + description: The import was accepted; poll the Operation for progress. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/Operation' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/ServerError' + /v1/operations/{operationId}: + parameters: + - $ref: '#/components/parameters/OperationId' + get: + operationId: getOperation + summary: Poll an Operation + description: >- + Returns the current state of a long-running Operation so a client can poll + for completion, per guide §15.4. + tags: [Operations] + parameters: + - $ref: '#/components/parameters/Traceparent' + responses: + '200': + description: The current Operation state. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/Operation' + '304': + $ref: '#/components/responses/NotModified' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' + /v1/operations/{operationId}/cancel: + parameters: + - $ref: '#/components/parameters/OperationId' + post: + operationId: cancelOperation + summary: Cancel an Operation + description: >- + Requests cancellation of a long-running Operation, per guide §15.5. Returns + the Operation with its updated status. + tags: [Operations] + security: + - registryOAuth: + - bb:registry:registrant:write + parameters: + - $ref: '#/components/parameters/Traceparent' + responses: + '200': + description: Cancellation was requested; the Operation state is returned. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + content: + application/json: + schema: + $ref: '#/components/schemas/Operation' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '500': + $ref: '#/components/responses/ServerError' + /v1/subscriptions: + get: + operationId: listSubscriptions + summary: List subscriptions + description: Returns a cursor-paginated page of webhook subscriptions. + tags: [Subscriptions] + security: + - registryOAuth: + - bb:registry:subscription:manage + parameters: + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/PageSize' + - $ref: '#/components/parameters/Cursor' + responses: + '200': + description: A page of subscriptions. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + ETag: + $ref: '#/components/headers/ETag' + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionPage' + '304': + $ref: '#/components/responses/NotModified' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/ServerError' + post: + operationId: createSubscription + summary: Create a subscription + description: >- + Registers a webhook subscription for registrant lifecycle events and + returns the created subscription. Idempotent via Idempotency-Key. + tags: [Subscriptions] + security: + - registryOAuth: + - bb:registry:subscription:manage + parameters: + - $ref: '#/components/parameters/Traceparent' + - $ref: '#/components/parameters/IdempotencyKey' + requestBody: + required: true + description: The subscription to create. + content: + application/json: + schema: + $ref: '#/components/schemas/SubscriptionCreate' + responses: + '201': + description: The subscription was created. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + Location: + $ref: '#/components/headers/Location' + content: + application/json: + schema: + $ref: '#/components/schemas/Subscription' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '500': + $ref: '#/components/responses/ServerError' + /v1/subscriptions/{subscriptionId}: + parameters: + - $ref: '#/components/parameters/SubscriptionId' + delete: + operationId: deleteSubscription + summary: Delete a subscription + description: Cancels and removes a webhook subscription. + tags: [Subscriptions] + security: + - registryOAuth: + - bb:registry:subscription:manage + parameters: + - $ref: '#/components/parameters/Traceparent' + responses: + '204': + description: The subscription was deleted; no body is returned. + headers: + traceparent: + $ref: '#/components/headers/Traceparent' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/ServerError' +webhooks: + registrantRegistered: + post: + operationId: onRegistrantRegistered + summary: Registrant registered event + description: >- + Delivered to a subscriber's callback URL when a registrant is registered. + The body is a structured CloudEvents 1.0 envelope. This baseline example + uses authenticated transport and does not opt into message signing. + tags: [Subscriptions] + requestBody: + required: true + description: A CloudEvents 1.0 envelope wrapping the registrant event. + content: + application/cloudevents+json: + schema: + $ref: '#/components/schemas/RegistrantRegisteredEvent' + responses: + '200': + description: The subscriber accepted the event. + '410': + description: >- + The subscriber endpoint is gone; the publisher may disable the + subscription. +components: + securitySchemes: + registryOAuth: + type: oauth2 + description: >- + OAuth 2.0 for citizen-facing and inter-BB access. The authorizationCode + flow serves user-facing clients; the clientCredentials flow serves + service-to-service (BB-to-BB) calls. + flows: + authorizationCode: + authorizationUrl: https://id.example.gov/oauth2/authorize + tokenUrl: https://id.example.gov/oauth2/token + refreshUrl: https://id.example.gov/oauth2/token + scopes: + bb:registry:registrant:read: Read registrant records. + bb:registry:registrant:write: Create, update and delete registrant records. + bb:registry:subscription:manage: Manage webhook subscriptions. + clientCredentials: + tokenUrl: https://id.example.gov/oauth2/token + scopes: + bb:registry:registrant:read: Read registrant records. + bb:registry:registrant:write: Create, update and delete registrant records. + bb:registry:subscription:manage: Manage webhook subscriptions. + registryOidc: + type: openIdConnect + description: OpenID Connect discovery for citizen authentication. + openIdConnectUrl: https://id.example.gov/.well-known/openid-configuration + registryMtls: + type: mutualTLS + description: Mutual TLS for high-assurance service-to-service calls. + parameters: + Traceparent: + name: traceparent + in: header + required: false + description: >- + W3C Trace Context traceparent (§8.4). The service propagates a valid + received context or creates a valid new context when absent or invalid. + schema: + type: string + pattern: '^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$' + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: >- + Unique key making a non-idempotent create safe to retry (§8.3/§14.1). The + server returns the original result for a repeated key. + schema: + type: string + maxLength: 255 + IfMatch: + name: If-Match + in: header + required: true + description: >- + ETag of the version the caller intends to modify, for optimistic + concurrency (§7.17). A mismatch yields 412. + schema: + type: string + RegistrantId: + name: registrantId + in: path + required: true + description: Opaque, server-generated identifier of a registrant. + schema: + type: string + format: uuid + OperationId: + name: operationId + in: path + required: true + description: Opaque identifier of a long-running Operation. + schema: + type: string + minLength: 1 + SubscriptionId: + name: subscriptionId + in: path + required: true + description: Opaque identifier of a webhook subscription. + schema: + type: string + format: uuid + PageSize: + name: pageSize + in: query + required: false + description: Maximum number of items to return in one page. + schema: + type: integer + minimum: 1 + default: 20 + maximum: 100 + Cursor: + name: cursor + in: query + required: false + description: >- + Opaque pagination cursor returned as pageInfo.nextCursor by a previous + call. Omit to fetch the first page. + schema: + type: string + Sort: + name: sort + in: query + required: false + description: >- + Comma-separated sort keys; prefix a key with "-" for descending order + (e.g. "-updatedAt,familyName"). + schema: + type: string + pattern: '^-?[a-zA-Z][a-zA-Z0-9]*(,-?[a-zA-Z][a-zA-Z0-9]*)*$' + FilterRegistrationStatus: + name: registrationStatus + in: query + required: false + description: Equality filter on the registrant's registration status. + schema: + type: string + pattern: '^[A-Z][A-Z0-9_]*$' + FilterCountryCode: + name: countryCode + in: query + required: false + description: Equality filter on the registrant's ISO 3166-1 alpha-2 country code. + schema: + type: string + pattern: '^[A-Z]{2}$' + headers: + Traceparent: + description: Optional W3C Trace Context returned for diagnostic convenience. + schema: + type: string + pattern: '^[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$' + Location: + description: Absolute URL of the created or referenced resource. + schema: + type: string + format: uri-reference + ETag: + description: Entity tag of the returned representation, for conditional requests. + schema: + type: string + CacheControl: + description: Caching directives; error and Operation-status bodies use no-store. + schema: + type: string + WwwAuthenticate: + description: Authentication challenge describing how to authenticate (RFC 9110). + schema: + type: string + responses: + NotModified: + description: The representation is unchanged from the caller's cached ETag. + headers: + ETag: + $ref: '#/components/headers/ETag' + BadRequest: + description: The request could not be parsed or its non-field parameters were invalid. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/bad-request + title: Bad request + status: 400 + detail: The cursor parameter is malformed. + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + ValidationFailed: + description: One or more request fields failed validation. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/ValidationProblem' + example: + type: https://govstack.global/problems/registry/invalid-field + title: Validation failed + status: 422 + detail: One or more fields are invalid. + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + errors: + - pointer: /emailAddress + message: Must be a valid email address. + Unauthorized: + description: Authentication is required or the supplied credentials are invalid. + headers: + WWW-Authenticate: + $ref: '#/components/headers/WwwAuthenticate' + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/authentication-required + title: Authentication required + status: 401 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + Forbidden: + description: The authenticated principal lacks a required OAuth scope. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/permission-denied + title: Permission denied + status: 403 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + NotFound: + description: The requested resource does not exist. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/resource-not-found + title: Resource not found + status: 404 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + Conflict: + description: The request conflicts with the current state of the resource. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/state-conflict + title: State conflict + status: 409 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + PreconditionFailed: + description: The If-Match precondition did not hold; the resource was modified. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/precondition-failed + title: Precondition failed + status: 412 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + UnsupportedMediaType: + description: The request media type is not supported by this endpoint. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/unsupported-media-type + title: Unsupported media type + status: 415 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + ServerError: + description: An unexpected error occurred while processing the request. + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/internal-error + title: Internal error + status: 500 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + ServiceUnavailable: + description: The service is temporarily unable to accept work (guide §5.9). + headers: + Cache-Control: + $ref: '#/components/headers/CacheControl' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://govstack.global/problems/registry/service-unavailable + title: Service unavailable + status: 503 + traceId: 8f0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d + schemas: + Problem: + $ref: '../../../../api/common/govstack-openapi-common.yaml#/components/schemas/Problem' + ValidationProblem: + $ref: '../../../../api/common/govstack-openapi-common.yaml#/components/schemas/ValidationProblem' + FieldError: + $ref: '../../../../api/common/govstack-openapi-common.yaml#/components/schemas/FieldError' + HealthStatus: + type: object + description: >- + Minimal application/json body accompanying a 200 from the liveness + endpoint. Health itself is carried by the status code (§5.9), so this + body is informational: it names the service and nothing more. Versions, + hostnames, and dependency topology are system-internal detail §5.9 + forbids here. + properties: + description: + type: string + description: Human-readable name of the service reporting health. + examples: + - description: health of the registry BB + Money: + type: object + description: >- + A monetary amount as a decimal string plus an ISO 4217 currency code + (§10.4); never a binary float. + required: [amount, currency] + properties: + amount: + type: string + description: Decimal amount as a string, e.g. "12.50". + pattern: '^-?[0-9]+(\.[0-9]+)?$' + currency: + type: string + description: ISO 4217 alphabetic currency code. + pattern: '^[A-Z]{3}$' + Registrant: + type: object + description: A registrant record held by the Registry Building Block. + required: + - registrantId + - givenName + - familyName + - registrationStatus + - createdAt + - updatedAt + properties: + registrantId: + type: string + format: uuid + description: Opaque, server-generated unique identifier of the registrant. + givenName: + type: string + description: The registrant's given (first) name. + familyName: + type: string + description: The registrant's family (last) name. + emailAddress: + type: string + format: email + description: The registrant's contact email address (RFC 5322). + phoneNumber: + type: string + description: The registrant's phone number in E.164 form. + pattern: '^\+[1-9]\d{1,14}$' + birthDate: + type: string + format: date + description: The registrant's date of birth (RFC 3339 full-date). + countryCode: + type: string + description: ISO 3166-1 alpha-2 country of residence. + pattern: '^[A-Z]{2}$' + preferredLanguage: + type: string + description: Preferred communication language as a BCP 47 tag. + pattern: '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$' + registrationStatus: + type: string + description: >- + Lifecycle status of the registration: ACTIVE, SUSPENDED or + DEREGISTERED. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + registrationFee: + $ref: '#/components/schemas/Money' + signature: + type: string + contentEncoding: base64 + maxLength: 100000 + description: Base64-encoded captured signature image, if provided (§10.7). + createdAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registrant was created. + updatedAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registrant was last modified. + examples: + - registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + givenName: Amina + familyName: Diallo + emailAddress: amina.diallo@example.com + phoneNumber: '+221771234567' + birthDate: '1990-04-12' + countryCode: SN + preferredLanguage: fr + registrationStatus: ACTIVE + registrationFee: + amount: '12.50' + currency: XOF + createdAt: '2026-01-15T09:00:00Z' + updatedAt: '2026-07-01T14:30:00Z' + RegistrantCreate: + type: object + description: Writable attributes accepted when creating or replacing a registrant. + required: [givenName, familyName] + properties: + givenName: + type: string + description: The registrant's given (first) name. + familyName: + type: string + description: The registrant's family (last) name. + emailAddress: + type: string + format: email + description: The registrant's contact email address (RFC 5322). + phoneNumber: + type: string + description: The registrant's phone number in E.164 form. + pattern: '^\+[1-9]\d{1,14}$' + birthDate: + type: string + format: date + description: The registrant's date of birth (RFC 3339 full-date). + countryCode: + type: string + description: ISO 3166-1 alpha-2 country of residence. + pattern: '^[A-Z]{2}$' + preferredLanguage: + type: string + description: Preferred communication language as a BCP 47 tag. + pattern: '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$' + registrationFee: + $ref: '#/components/schemas/Money' + examples: + - givenName: Amina + familyName: Diallo + emailAddress: amina.diallo@example.com + phoneNumber: '+221771234567' + birthDate: '1990-04-12' + countryCode: SN + preferredLanguage: fr + RegistrantPatch: + type: object + description: >- + JSON Merge Patch body for a registrant (§6.4). Present fields are replaced; + an explicit null clears a field. + properties: + emailAddress: + type: string + format: email + description: New contact email address. + phoneNumber: + type: string + description: New phone number in E.164 form. + pattern: '^\+[1-9]\d{1,14}$' + preferredLanguage: + type: string + description: New preferred language as a BCP 47 tag. + pattern: '^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$' + registrationStatus: + type: string + description: New registration status. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + examples: + - emailAddress: amina.new@example.com + registrationStatus: SUSPENDED + RegistrantSearchRequest: + type: object + description: Complex search filter plus cursor pagination controls (§12.9). + properties: + givenName: + type: string + description: Match registrants whose given name equals this value. + familyName: + type: string + description: Match registrants whose family name equals this value. + countryCode: + type: string + description: Match registrants in this ISO 3166-1 alpha-2 country. + pattern: '^[A-Z]{2}$' + registrationStatus: + type: string + description: Match registrants with this registration status. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + pageSize: + type: integer + description: Maximum number of items to return in one page. + minimum: 1 + default: 20 + maximum: 100 + cursor: + type: string + description: Opaque pagination cursor from a previous page. + examples: + - countryCode: SN + registrationStatus: ACTIVE + pageSize: 20 + PageInfo: + description: >- + Page metadata. total, when present, is exact and describes the + collection at the time this page is generated. + $ref: '../../../../api/common/govstack-openapi-common.yaml#/components/schemas/PageInfo' + RegistrantPage: + type: object + description: A cursor-paginated page of registrant records. + required: [items, pageInfo] + properties: + items: + type: array + description: The registrant records in this page. + items: + $ref: '#/components/schemas/Registrant' + pageInfo: + $ref: '#/components/schemas/PageInfo' + examples: + - items: + - registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + givenName: Amina + familyName: Diallo + registrationStatus: ACTIVE + createdAt: '2026-01-15T09:00:00Z' + updatedAt: '2026-07-01T14:30:00Z' + pageInfo: + nextCursor: b3BhcXVlLWN1cnNvci0y + SubscriptionPage: + type: object + description: A cursor-paginated page of webhook subscriptions. + required: [items, pageInfo] + properties: + items: + type: array + description: The subscriptions in this page. + items: + $ref: '#/components/schemas/Subscription' + pageInfo: + $ref: '#/components/schemas/PageInfo' + examples: + - items: + - subscriptionId: 9d1f3b2a-6c4e-4a1b-8f0d-2e5c7a9b1d33 + callbackUrl: https://partner.example.org/hooks/registry + eventTypes: + - global.govstack.registry.registrant.registered + status: ACTIVE + createdAt: '2026-06-01T08:00:00Z' + updatedAt: '2026-06-01T08:00:00Z' + pageInfo: + nextCursor: null + BulkImportRequest: + type: object + description: Instructions for a long-running bulk import. + required: [sourceUrl] + properties: + sourceUrl: + type: string + format: uri + description: URL of the source dataset to import. + dryRun: + type: boolean + description: When true, validate the source without persisting records. + examples: + - sourceUrl: https://data.example.gov/registry/import-2026-07.csv + dryRun: false + Operation: + type: object + description: >- + A long-running asynchronous Operation resource (§15.2). Clients poll it + until status is terminal. + required: [id, status, result, error, createdAt, updatedAt] + properties: + id: + type: string + minLength: 1 + description: Opaque identifier of the Operation. + status: + type: string + description: >- + Lifecycle status of the Operation: PENDING, RUNNING, SUCCEEDED, FAILED + or CANCELLED. + enum: [PENDING, RUNNING, SUCCEEDED, FAILED, CANCELLED] + x-extensible-enum: true + progress: + type: integer + description: Best-effort completion percentage from 0 to 100. + minimum: 0 + maximum: 100 + result: + type: [object, 'null'] + description: >- + Operation-specific result payload once status is SUCCEEDED; null + otherwise. + error: + type: [object, 'null'] + description: >- + Problem details once status is FAILED; null otherwise. + createdAt: + type: string + format: date-time + description: RFC 3339 timestamp when the Operation was created. + updatedAt: + type: string + format: date-time + description: RFC 3339 timestamp when the Operation last changed state. + examples: + - id: op_7JpQ9m2W4xK8fR3cT6vN1 + status: RUNNING + progress: 42 + result: null + error: null + createdAt: '2026-07-10T12:00:00Z' + updatedAt: '2026-07-10T12:03:00Z' + Subscription: + type: object + description: A webhook subscription for registrant lifecycle events. + required: [subscriptionId, callbackUrl, eventTypes, status, createdAt, updatedAt] + properties: + subscriptionId: + type: string + format: uuid + description: Opaque identifier of the subscription. + callbackUrl: + type: string + format: uri + description: HTTPS URL the events are delivered to. + eventTypes: + type: array + description: Reverse-DNS event types this subscription receives. + items: + type: string + description: A single reverse-DNS event type. + status: + type: string + description: Whether the subscription is ACTIVE or PAUSED. + enum: [ACTIVE, PAUSED] + x-extensible-enum: true + createdAt: + type: string + format: date-time + description: RFC 3339 timestamp when the subscription was created. + updatedAt: + type: string + format: date-time + description: RFC 3339 timestamp when the subscription was last modified. + examples: + - subscriptionId: 9d1f3b2a-6c4e-4a1b-8f0d-2e5c7a9b1d33 + callbackUrl: https://partner.example.org/hooks/registry + eventTypes: + - global.govstack.registry.registrant.registered + status: ACTIVE + createdAt: '2026-06-01T08:00:00Z' + updatedAt: '2026-06-01T08:00:00Z' + SubscriptionCreate: + type: object + description: Attributes accepted when creating a subscription. + required: [callbackUrl, eventTypes] + properties: + callbackUrl: + type: string + format: uri + description: HTTPS URL the events will be delivered to. + eventTypes: + type: array + description: Reverse-DNS event types to subscribe to. + items: + type: string + description: A single reverse-DNS event type. + examples: + - callbackUrl: https://partner.example.org/hooks/registry + eventTypes: + - global.govstack.registry.registrant.registered + RegistrantRegisteredEvent: + type: object + description: >- + CloudEvents 1.0 envelope delivered when a registrant is registered (§16.2). + GovStack domain data lives under data. + required: [specversion, id, source, type, time, datacontenttype, data] + properties: + specversion: + type: string + description: CloudEvents specification version; always "1.0". + const: '1.0' + id: + type: string + description: Unique identifier of this event occurrence. + source: + type: string + description: >- + Stable logical identifier of the publishing context; never a + deployment host, pod, broker or environment (§16.4). + const: /govstack/registry + type: + type: string + description: >- + Reverse-DNS event type, global.govstack.{bb-code}.{resource}.{action}, + carrying no version segment (§16.3). + const: global.govstack.registry.registrant.registered + time: + type: string + format: date-time + description: RFC 3339 timestamp at which the event occurred. + datacontenttype: + type: string + description: Media type of the data member. + const: application/json + subject: + type: string + description: Identifier of the registrant the event concerns. + data: + $ref: '#/components/schemas/RegistrantEventData' + examples: + - specversion: '1.0' + id: 0b8c1d2e-3f4a-5b6c-7d8e-9f0a1b2c3d4e + source: /govstack/registry + type: global.govstack.registry.registrant.registered + time: '2026-07-10T12:34:56Z' + datacontenttype: application/json + subject: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + data: + registrantId: 6f9619ff-8b86-d011-b42d-00cf4fc964ff + registrationStatus: ACTIVE + occurredAt: '2026-07-10T12:34:56Z' + RegistrantEventData: + type: object + description: GovStack domain payload for a registrant-registered event. + required: [registrantId, registrationStatus, occurredAt] + properties: + registrantId: + type: string + format: uuid + description: Identifier of the registrant that was registered. + registrationStatus: + type: string + description: Registration status at the time of the event. + enum: [ACTIVE, SUSPENDED, DEREGISTERED] + x-extensible-enum: true + occurredAt: + type: string + format: date-time + description: RFC 3339 timestamp when the registration occurred. diff --git a/api-design-guide/linter/tests/harness.test.mjs b/api-design-guide/linter/tests/harness.test.mjs new file mode 100644 index 0000000..b325698 --- /dev/null +++ b/api-design-guide/linter/tests/harness.test.mjs @@ -0,0 +1,70 @@ +// Sanity checks for the plumbing: the shipped bundle loads, and Spectral's +// format detection classifies minimal AsyncAPI 3.0.0 / OpenAPI 3.1.0 documents +// as we rely on in the fragments. The format probe rules below are built inline +// for the test and are NOT part of the shipped ruleset. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import corePkg from '@stoplight/spectral-core'; +const { Spectral, Document } = corePkg; +import SpectralFunctions from '@stoplight/spectral-functions'; +import SpectralFormats from '@stoplight/spectral-formats'; +import Parsers from '@stoplight/spectral-parsers'; +import { loadRuleset, STRICT_PATH } from './_lint-helper.mjs'; + +const { truthy } = SpectralFunctions; +const { aas3, oas3_1 } = SpectralFormats; + +test('shipped strict bundle loads and includes the §2 proof-of-concept rules', async () => { + const ruleset = await loadRuleset(STRICT_PATH); + const names = Object.keys(ruleset.rules); + assert.ok(names.length > 0, 'bundle has no rules'); + for (const expected of ['govstack-2.1', 'govstack-2.5', 'govstack-2.5-semver', 'govstack-2.6', 'govstack-2.7']) { + assert.ok(names.includes(expected), `bundle missing ${expected}`); + } +}); + +// A probe rule that fires whenever its `formats` gate lets it run. +function probeSpectral(formatFn) { + const s = new Spectral(); + s.setRuleset({ + rules: { + probe: { + given: '$', + severity: 'error', + formats: [formatFn], + then: { field: '__format_probe_absent__', function: truthy }, + }, + }, + }); + return s; +} +const run = (spectral, obj) => spectral.run(new Document(JSON.stringify(obj), Parsers.Json, 'probe.json')); + +const ASYNC_DOC = { + asyncapi: '3.0.0', + info: { title: 'x', version: '1.0.0' }, + channels: {}, + operations: {}, +}; +const OPENAPI_DOC = { + openapi: '3.1.0', + info: { title: 'x', version: '1.0.0' }, + paths: {}, +}; + +test('minimal AsyncAPI 3.0.0 document is detected as aas3', async () => { + const spectral = probeSpectral(aas3); + const onAsync = (await run(spectral, ASYNC_DOC)).filter((r) => r.code === 'probe'); + const onOpenapi = (await run(spectral, OPENAPI_DOC)).filter((r) => r.code === 'probe'); + assert.equal(onAsync.length, 1, 'aas3 probe should fire on the AsyncAPI document'); + assert.equal(onOpenapi.length, 0, 'aas3 probe must not fire on the OpenAPI document'); +}); + +test('minimal OpenAPI 3.1.0 document is detected as oas3_1', async () => { + const spectral = probeSpectral(oas3_1); + const onOpenapi = (await run(spectral, OPENAPI_DOC)).filter((r) => r.code === 'probe'); + const onAsync = (await run(spectral, ASYNC_DOC)).filter((r) => r.code === 'probe'); + assert.equal(onOpenapi.length, 1, 'oas3_1 probe should fire on the OpenAPI document'); + assert.equal(onAsync.length, 0, 'oas3_1 probe must not fire on the AsyncAPI document'); +}); diff --git a/api-design-guide/linter/tests/message-examples.test.mjs b/api-design-guide/linter/tests/message-examples.test.mjs new file mode 100644 index 0000000..1ecba5d --- /dev/null +++ b/api-design-guide/linter/tests/message-examples.test.mjs @@ -0,0 +1,56 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import YAML from 'yaml'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const goldenPath = path.join(here, 'golden/asyncapi-golden.yaml'); +const commonPath = path.resolve(here, '../../../api/common/govstack-asyncapi-common.yaml'); +const golden = YAML.parse(await readFile(goldenPath, 'utf8')); +const common = YAML.parse(await readFile(commonPath, 'utf8')); + +function atPointer(root, fragment) { + return fragment + .replace(/^#\//, '') + .split('/') + .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~')) + .reduce((value, part) => value?.[part], root); +} + +function dereference(value, sourceRoot = golden) { + if (Array.isArray(value)) return value.map((item) => dereference(item, sourceRoot)); + if (value === null || typeof value !== 'object') return value; + + if (typeof value.$ref === 'string') { + const [file, fragment = ''] = value.$ref.split('#'); + const refRoot = file.includes('govstack-asyncapi-common.yaml') ? common : sourceRoot; + const target = atPointer(refRoot, `#${fragment}`); + assert.ok(target, `Unresolved schema reference in golden example test: ${value.$ref}`); + return dereference(target, refRoot); + } + + return Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, dereference(child, sourceRoot)]), + ); +} + +test('every AsyncAPI golden payload example satisfies its composed schema', () => { + const ajv = new Ajv({ allErrors: true, strict: false }); + addFormats(ajv); + + for (const [messageName, message] of Object.entries(golden.components.messages)) { + const validate = ajv.compile(dereference(message.payload)); + for (const example of message.examples ?? []) { + const valid = validate(example.payload); + assert.equal( + valid, + true, + `${messageName}/${example.name} does not satisfy its payload schema:\n${JSON.stringify(validate.errors, null, 2)}`, + ); + } + } +}); diff --git a/api-design-guide/linter/tests/openapi-examples.test.mjs b/api-design-guide/linter/tests/openapi-examples.test.mjs new file mode 100644 index 0000000..be6f448 --- /dev/null +++ b/api-design-guide/linter/tests/openapi-examples.test.mjs @@ -0,0 +1,203 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import YAML from 'yaml'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const goldenPath = path.join(here, 'golden/openapi-golden.yaml'); +const commonPath = path.resolve(here, '../../../api/common/govstack-openapi-common.yaml'); +const golden = YAML.parse(await readFile(goldenPath, 'utf8')); +const common = YAML.parse(await readFile(commonPath, 'utf8')); +const documents = new Map([ + [goldenPath, golden], + [commonPath, common], +]); + +const EXPECTED_COMMON_REFS = new Set( + ['Problem', 'ValidationProblem', 'FieldError', 'PageInfo'].map( + (name) => `../../../../api/common/govstack-openapi-common.yaml#/components/schemas/${name}`, + ), +); + +function atPointer(root, fragment) { + if (fragment === '' || fragment === '#') return root; + assert.match(fragment, /^#\//, `Only local JSON Pointer fragments are supported: ${fragment}`); + return fragment + .slice(2) + .split('/') + .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~')) + .reduce((value, part) => value?.[part], root); +} + +function dereference(value, sourcePath, stack = new Set()) { + if (Array.isArray(value)) return value.map((item) => dereference(item, sourcePath, stack)); + if (value === null || typeof value !== 'object') return value; + + if (typeof value.$ref === 'string') { + const hash = value.$ref.indexOf('#'); + const file = hash === -1 ? value.$ref : value.$ref.slice(0, hash); + const fragment = hash === -1 ? '' : value.$ref.slice(hash); + const targetPath = file ? path.resolve(path.dirname(sourcePath), file) : sourcePath; + const targetRoot = documents.get(targetPath); + assert.ok(targetRoot, `Unresolved document in OpenAPI example test: ${value.$ref}`); + const target = atPointer(targetRoot, fragment); + assert.notEqual(target, undefined, `Unresolved JSON Pointer in OpenAPI example test: ${value.$ref}`); + + const key = `${targetPath}${fragment}`; + assert.ok(!stack.has(key), `Cyclic schema reference is not supported by this example test: ${key}`); + const nextStack = new Set(stack).add(key); + const resolved = dereference(target, targetPath, nextStack); + const siblings = Object.fromEntries(Object.entries(value).filter(([name]) => name !== '$ref')); + if (Object.keys(siblings).length === 0) return resolved; + return { allOf: [resolved, dereference(siblings, sourcePath, stack)] }; + } + + return Object.fromEntries( + Object.entries(value).map(([name, child]) => [name, dereference(child, sourcePath, stack)]), + ); +} + +function collectRefs(value, refs = []) { + if (Array.isArray(value)) { + for (const item of value) collectRefs(item, refs); + return refs; + } + if (value === null || typeof value !== 'object') return refs; + if (typeof value.$ref === 'string') refs.push(value.$ref); + for (const child of Object.values(value)) collectRefs(child, refs); + return refs; +} + +function makeAjv() { + const ajv = new Ajv2020({ allErrors: true, strict: false }); + addFormats(ajv); + return ajv; +} + +function assertValid(validate, value, label) { + assert.equal( + validate(value), + true, + `${label} does not satisfy its schema:\n${JSON.stringify(validate.errors, null, 2)}`, + ); +} + +function assertInvalid(validate, value, label) { + assert.equal(validate(value), false, `${label} unexpectedly satisfied its schema`); +} + +test('OpenAPI golden consumes only the four approved common schemas', () => { + const commonRefs = collectRefs(golden).filter((ref) => ref.includes('govstack-openapi-common.yaml')); + assert.deepEqual(new Set(commonRefs), EXPECTED_COMMON_REFS); + assert.equal(commonRefs.length, EXPECTED_COMMON_REFS.size, 'each common schema should be aliased once'); + + for (const name of ['Problem', 'ValidationProblem', 'FieldError', 'PageInfo']) { + assert.equal( + golden.components.schemas[name].$ref, + `../../../../api/common/govstack-openapi-common.yaml#/components/schemas/${name}`, + ); + } + assert.equal(golden.components.schemas.Operation.$ref, undefined, 'Operation must remain BB-owned'); +}); + +test('common OpenAPI schemas validate their contract while remaining open to undeclared members', () => { + const ajv = makeAjv(); + const compile = (name) => ajv.compile(dereference(common.components.schemas[name], commonPath)); + const traceId = '4bf92f3577b34da6a3ce929d0e0e4736'; + + const problem = compile('Problem'); + const validProblem = { + type: 'https://govstack.global/problems/registry/record-not-found', + title: 'Record not found', + status: 404, + traceId, + }; + assertValid(problem, validProblem, 'Problem positive control'); + assertInvalid(problem, { ...validProblem, type: 'https://docs.example.gov/problems/not-found' }, 'Problem old type URI'); + assertInvalid(problem, { ...validProblem, type: 'https://govstack.global/problems/registry/recordNotFound' }, 'Problem non-kebab slug'); + assertValid(problem, { ...validProblem, code: 'consumer-extension' }, 'Problem undeclared member control'); + assertValid(problem, { ...validProblem, timestamp: '2026-07-10T12:34:56Z' }, 'Problem timestamp extension control'); + const missingTrace = { ...validProblem }; + delete missingTrace.traceId; + assertInvalid(problem, missingTrace, 'Problem missing traceId'); + + const fieldError = compile('FieldError'); + assertValid(fieldError, { pointer: '/emailAddress', message: 'Must be a valid email.' }, 'FieldError positive control'); + assertInvalid(fieldError, { pointer: 'emailAddress', message: 'Must be a valid email.' }, 'FieldError invalid JSON Pointer'); + assertInvalid(fieldError, { pointer: '/emailAddress' }, 'FieldError missing message'); + assertValid( + fieldError, + { pointer: '/emailAddress', code: 'consumer-extension', message: 'Must be a valid email.' }, + 'FieldError undeclared member control', + ); + + const validation = compile('ValidationProblem'); + const validValidation = { + type: 'https://govstack.global/problems/registry/invalid-field', + title: 'Validation failed', + status: 422, + traceId, + errors: [{ pointer: '/emailAddress', message: 'Must be a valid email.' }], + }; + assertValid(validation, validValidation, 'ValidationProblem positive control'); + assertInvalid(validation, { ...validValidation, errors: [] }, 'ValidationProblem empty errors'); + assertInvalid( + validation, + { ...validValidation, errors: [{ pointer: '/emailAddress' }] }, + 'ValidationProblem field error missing message', + ); + + const pageInfo = compile('PageInfo'); + assertValid(pageInfo, { nextCursor: null }, 'PageInfo final-page control'); + assertValid(pageInfo, { nextCursor: 'opaque-cursor', total: 12 }, 'PageInfo continuation control'); + assertValid(pageInfo, { nextCursor: null, hasMore: false }, 'PageInfo undeclared member control'); + assertInvalid(pageInfo, {}, 'PageInfo missing nextCursor'); + assertInvalid(pageInfo, { nextCursor: '' }, 'PageInfo empty cursor'); +}); + +test('every OpenAPI golden example satisfies its resolved schema', () => { + const ajv = makeAjv(); + let checked = 0; + + const validateExamples = (schema, examples, label) => { + const validate = ajv.compile(dereference(schema, goldenPath)); + for (const [index, example] of examples.entries()) { + assertValid(validate, example, `${label} example ${index + 1}`); + checked += 1; + } + }; + + for (const [name, schema] of Object.entries(golden.components.schemas)) { + const examples = Array.isArray(schema.examples) + ? schema.examples + : Object.prototype.hasOwnProperty.call(schema, 'example') + ? [schema.example] + : []; + if (examples.length > 0) validateExamples(schema, examples, `components.schemas.${name}`); + } + + const walk = (value, label) => { + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, `${label}[${index}]`)); + return; + } + if (value === null || typeof value !== 'object') return; + if (value.schema && Object.prototype.hasOwnProperty.call(value, 'example')) { + validateExamples(value.schema, [value.example], label); + } + if (value.schema && value.examples && !Array.isArray(value.examples)) { + const examples = Object.values(value.examples) + .filter((example) => !example?.$ref) + .map((example) => (example && typeof example === 'object' && 'value' in example ? example.value : example)); + validateExamples(value.schema, examples, label); + } + for (const [name, child] of Object.entries(value)) walk(child, `${label}.${name}`); + }; + walk({ paths: golden.paths, webhooks: golden.webhooks, responses: golden.components.responses }, 'openapi'); + + assert.ok(checked > 0, 'golden must contain schema-bound examples'); +}); diff --git a/api-design-guide/linter/tests/run-fixtures.test.mjs b/api-design-guide/linter/tests/run-fixtures.test.mjs new file mode 100644 index 0000000..8382f7e --- /dev/null +++ b/api-design-guide/linter/tests/run-fixtures.test.mjs @@ -0,0 +1,77 @@ +// Data-driven fixture runner. +// +// Every directory under tests/fixtures/ is named after a Spectral rule and +// contains fail.yaml (a minimal doc where that rule MUST fire) and pass.yaml +// (the corrected doc where that rule MUST be silent). Optional meta.yaml with +// `expect_fail_count: N` pins the number of findings in fail.yaml. +// +// The full strict bundle (ruleset.yaml + strict.yaml) is loaded once; findings +// are filtered by `code === ` so unrelated rules firing on a fixture +// are ignored (expected and fine). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, existsSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import YAML from 'yaml'; +import { makeSpectral, lintFile } from './_lint-helper.mjs'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = join(HERE, 'fixtures'); + +const spectral = await makeSpectral(); // strict bundle, once + +const dirs = existsSync(FIXTURES) + ? readdirSync(FIXTURES, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name) + .sort() + : []; + +const fmt = (findings) => + JSON.stringify(findings.map((f) => ({ message: f.message, path: f.path.join('/') })), null, 2); + +for (const name of dirs) { + test(`fixture ${name}`, async () => { + const dir = join(FIXTURES, name); + const failPath = join(dir, 'fail.yaml'); + const passPath = join(dir, 'pass.yaml'); + assert.ok(existsSync(failPath), `${name}: missing fail.yaml`); + assert.ok(existsSync(passPath), `${name}: missing pass.yaml`); + + let expectExact = null; + const metaPath = join(dir, 'meta.yaml'); + if (existsSync(metaPath)) { + const meta = YAML.parse(readFileSync(metaPath, 'utf8')) || {}; + if (Number.isInteger(meta.expect_fail_count)) expectExact = meta.expect_fail_count; + } + + const fail = (await lintFile(spectral, failPath)).filter((f) => f.code === name); + const pass = (await lintFile(spectral, passPath)).filter((f) => f.code === name); + + if (expectExact === null) { + assert.ok( + fail.length >= 1, + `${name}/fail.yaml: expected >=1 finding of "${name}", got ${fail.length}.`, + ); + } else { + assert.equal( + fail.length, + expectExact, + `${name}/fail.yaml: expected exactly ${expectExact} findings of "${name}" ` + + `(meta.yaml), got ${fail.length}:\n${fmt(fail)}`, + ); + } + + assert.equal( + pass.length, + 0, + `${name}/pass.yaml: expected 0 findings of "${name}", got ${pass.length}:\n${fmt(pass)}`, + ); + }); +} + +test('fixtures directory is non-empty', () => { + assert.ok(dirs.length > 0, 'no fixture directories found under tests/fixtures/'); +}); diff --git a/api-design-guide/part-a/2-openapi-document-standards.md b/api-design-guide/part-a/2-openapi-document-standards.md new file mode 100644 index 0000000..3f3a6ba --- /dev/null +++ b/api-design-guide/part-a/2-openapi-document-standards.md @@ -0,0 +1,49 @@ +--- +description: "Rules governing the canonical OpenAPI document: version, location, validation, metadata, and conditional schema reuse." +--- + +# 2. OpenAPI document standards + +{% hint style="info" %} +**Intent.** Every OpenAPI surface has exactly one canonical, machine-validatable artifact in a known location or registry. Implementers must not have to choose between divergent copies. + +**Applies to:** OpenAPI surface. AsyncAPI document-level rules are in [§3](../part-a/3-asyncapi-document-standards.md). +{% endhint %} + +## 2.1 OpenAPI 3.1 required + +**[M]** The spec **MUST** declare an explicit, published OpenAPI 3.1 patch version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.1.0-draft` qualify `openapi: 3.1.0`, `3.1.1`, and `3.1.2`; tooling **MUST** treat those patches as the same OAS 3.1 feature set. OpenAPI 3.0 and earlier **MUST NOT** be used. A later OpenAPI minor version, including 3.2, **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it. + +## 2.2 One canonical OpenAPI entrypoint + +**[M+R]** In the absence of `api/index.yaml`, the canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned API surfaces **MUST** enumerate every surface in `api/index.yaml` using [§4.5](../part-a/4-documentation-requirements.md#45-api-surface-inventory). Either discovery form **MUST** identify exactly one canonical artifact per surface. The legacy `api/swagger.yaml` and `api/swagger.json` names still used by some BBs are not canonical under this guide. + +## 2.3 No divergent OpenAPI copies + +**[R]** Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent copies. OpenAPI snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it. + +An operation-free shared component library under `api/common/` is referenced support material, not a canonical API surface or a divergent copy. + +## 2.4 Passes openapi-spec-validator + +**[M]** The canonical entrypoint **MUST** pass `openapi-spec-validator` for its declared OpenAPI 3.1 patch version, with every local reference resolved. Referenced JSON Schema fragments **MUST** validate against their declared dialect but are not required to be standalone OpenAPI documents. + +## 2.5 Complete info block + +**[M]** The `info` block of each canonical file **MUST** include `title`, `version` (SemVer), and a useful `description`. It **SHOULD** include `contact`; repository governance may supply the maintainer contact when it does not belong in the API contract. Where a BB ships per-surface canonical files ([2.2](#22-one-canonical-openapi-entrypoint)), each surface carries its own `info.version` and versions independently. + +## 2.6 Meaningful servers block + +**[M+R]** The `servers` block **MUST** be non-empty and **MUST** describe the intended deployment base URL pattern for the API. Every non-local server URL **MUST** use `https`. Reference specifications that are not tied to a live implementation **SHOULD** use parameterised template URLs with documented variables (for example, `https://{gatewayHost}/{bbCode}`). Because [§5.1](../part-b/5-url-structure-and-versioning.md#51-major-version-in-the-path) places `/v{N}` in each OpenAPI path key, a server URL **MUST NOT** repeat that version segment. Server URLs **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production domains. Reserved documentation domains (for example, `example.org`) **MAY** be used only as variable defaults or examples and **MUST** be labelled as non-production. + +## 2.7 Complete operation metadata + +**[M+R]** Every operation **MUST** include a stable, non-empty `operationId` and an accurate `description`. An `operationId` **SHOULD** use a readable camelCase verb-noun form. A concise `summary` and at least one useful `tag` **SHOULD** be present when they improve navigation or generated documentation. + +## 2.8 Conditional vendored OpenAPI schemas + +**[M+R]** A BB **MAY** reuse the schema-only `govstack-openapi-common.yaml` artifact for `Problem`, `ValidationProblem`, `FieldError`, and `PageInfo`. If it does, the file **MUST** be vendored locally at `api/common/govstack-openapi-common.yaml`, its version **MUST** be pinned explicitly, and the BB **MUST** reference the named schemas rather than copy them. A BB that does not reuse the artifact **MUST** define equivalent schemas locally that satisfy [§11](../part-c/11-errors.md) and [§12](../part-c/12-pagination-filtering-sorting.md). + +Security schemes, parameters, headers, Response Objects, examples, and Operation resources **MUST** be defined locally because their values and semantics belong to the BB contract. They are not part of the shared OpenAPI artifact. + +Vendoring is required when reuse is chosen because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable. diff --git a/api-design-guide/part-a/3-asyncapi-document-standards.md b/api-design-guide/part-a/3-asyncapi-document-standards.md new file mode 100644 index 0000000..4fb2ee6 --- /dev/null +++ b/api-design-guide/part-a/3-asyncapi-document-standards.md @@ -0,0 +1,49 @@ +--- +description: "Rules governing the canonical AsyncAPI document: version, location, validation, metadata, and vendored shared components." +--- + +# 3. AsyncAPI document standards + +{% hint style="info" %} +**Intent.** Event-driven BB surfaces other than HTTP push webhooks have the same level of discoverability and mechanical validity as REST surfaces. Implementers must be able to identify each canonical AsyncAPI artifact, validate it, and understand which CloudEvents messages the BB sends or receives. + +**Applies to:** AsyncAPI surface. +{% endhint %} + +## 3.1 AsyncAPI 3.0.0 required + +**[M]** An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3 and **MUST** declare an explicit, published AsyncAPI 3 version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.1.0-draft` qualify `asyncapi: 3.0.0` and `asyncapi: 3.1.0`; every rule in this guide applies identically to both. AsyncAPI 2.x and earlier **MUST NOT** be used for new GovStack event-driven surfaces. A later AsyncAPI version **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it. + +## 3.2 One canonical AsyncAPI entrypoint + +**[M+R]** In the absence of `api/index.yaml`, the canonical AsyncAPI entrypoint **MUST** be located at `api/asyncapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned event-driven surfaces **MUST** enumerate every surface in `api/index.yaml` using [§4.5](../part-a/4-documentation-requirements.md#45-api-surface-inventory). Either discovery form **MUST** identify exactly one canonical artifact per surface. + +## 3.3 No divergent AsyncAPI copies + +**[R]** Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent AsyncAPI copies. Event snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it. + +An operation-free shared component library under `api/common/` is referenced support material, not a canonical API surface or a divergent copy. + +## 3.4 Passes an AsyncAPI validator + +**[M]** The file **MUST** pass an AsyncAPI 3.0 parser/validator (for example, `@asyncapi/parser` or the AsyncAPI CLI). + +## 3.5 Complete AsyncAPI info block + +**[M]** The `info` block of each canonical AsyncAPI file **MUST** include `title`, `version` (SemVer), and a useful `description`. It **SHOULD** include `contact`; repository governance may supply the maintainer contact when it does not belong in the API contract. + +## 3.6 Servers channels operations and messages + +**[M+R]** The file **MUST** declare non-empty `servers`, `channels`, `operations`, and `components.messages`. A document with only schemas and no operations is not an API contract. AsyncAPI `servers` **MUST** describe the intended broker or transport endpoint pattern using AsyncAPI 3.0 server fields (`host`, `protocol`, optional `pathname`, variables, security, and protocol bindings). Reference specifications that are not tied to a live broker **SHOULD** use parameterised server hosts and variables (for example, `host: "{brokerHost}"` with `protocol: mqtt`, `protocol: amqp`, `protocol: kafka`, or `protocol: wss`). Server definitions **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production brokers. Reserved documentation domains **MAY** be used only as variable defaults or examples, and **MUST** be labelled as non-production. + +## 3.7 Complete AsyncAPI operation metadata + +**[M+R]** Every AsyncAPI operation **MUST** have a stable operation identifier (the key under `operations`), an `action` (`send` or `receive`), an accurate `description`, a referenced `channel`, and at least one referenced message. A concise `summary` and at least one useful `tag` **SHOULD** be present when they improve navigation or generated documentation. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`. [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads)–[§17.7](../part-d/17-asyncapi-channel-rules.md#177-shared-cloudevents-envelope-schema) define which domain messages use CloudEvents and how asynchronous rejection messages reuse the common error schema. + +## 3.8 Pinned vendored AsyncAPI components + +**[M]** A BB that documents GovStack domain events **MUST** reference `CloudEventEnvelope` from a pinned version of `govstack-asyncapi-common.yaml`. A BB that documents asynchronous rejections **MUST** reference `GovStackAsyncError`, and **MUST** reuse `AsyncFieldError` when it exposes field-level errors. The common file **MUST** be vendored locally at `api/common/govstack-asyncapi-common.yaml`, and the pinned version **MUST** be explicit. BB specifications own their Message Objects, security schemes, headers, examples, and protocol bindings because those objects require BB- and transport-specific values. + +## 3.9 JSON Schema payload conventions + +**[M]** AsyncAPI documents **MUST** use JSON Schema compatible with AsyncAPI 3.0 for payload schemas and **MUST** follow the JSON conventions in [§9](../part-c/9-json-conventions-and-naming.md) and [§10](../part-c/10-data-types-and-formats.md) for GovStack-owned payload fields. diff --git a/api-design-guide/part-a/4-documentation-requirements.md b/api-design-guide/part-a/4-documentation-requirements.md new file mode 100644 index 0000000..f367654 --- /dev/null +++ b/api-design-guide/part-a/4-documentation-requirements.md @@ -0,0 +1,83 @@ +--- +description: "Documentation requirements for schemas, examples, and operation descriptions across OpenAPI and AsyncAPI surfaces." +--- + +# 4. Documentation requirements + +{% hint style="info" %} +**Intent.** The spec is read by implementers, not only by tools. Operations and schemas need human-readable prose. + +**Applies to:** Universal (OpenAPI and AsyncAPI surfaces). +{% endhint %} + +## 4.1 Useful schema descriptions + +**[M]** A schema **MUST** have a `description` when its name and structure do not make its semantics clear. Other schemas **SHOULD** have concise descriptions. Filler text added only to satisfy a universal presence check is not useful documentation. + +## 4.2 Examples for bodies and enums + +**[M+R]** Request and response bodies **SHOULD** have representative examples, especially when conditional fields or multi-step behavior are involved. Every `enum` whose values are not self-explanatory **MUST** document what its values mean; an example alone is insufficient. + +## 4.3 No placeholder text + +**[M+R]** The spec **MUST NOT** contain placeholder text: `TBD`, `Lorem ipsum`, `a, b, c`, content from other BBs with the original BB name still present, or test plans with literal placeholder steps. + +## 4.4 Accurate operation descriptions + +**[R]** Operation `description` **MUST** describe what the operation actually does. (Copy-pasted descriptions that document a different endpoint than the one they sit on are a recurring problem in existing BB specifications.) + +## 4.5 API surface inventory + +**[M+R]** A BB that uses only `api/openapi.yaml`, only `api/asyncapi.yaml`, or both default canonical paths **MAY** omit `api/index.yaml`. If neither default file exists, the BB **MUST** provide `api/index.yaml`. The index **MUST** contain `version: 1` and exactly one of: a non-empty `apis` list, or `noApi: true` together with a non-empty `reason`. + +An OpenAPI or AsyncAPI entry **MUST** contain `type` (`openapi` or `asyncapi`) and `path` (a unique, repository-relative YAML path inside `api/`), and every listed path **MUST** resolve to a canonical specification of the declared type. A surface governed directly by a recognised protocol standard **MAY** instead use `type: standard` with a non-empty `name`, an absolute HTTPS `reference` to its normative specification or profile, and an optional `discovery` value naming its standard discovery endpoint. For example, an OpenID Connect provider can point to the OIDC specification and `/.well-known/openid-configuration`; it does not need a synthetic OpenAPI description of standard protocol endpoints. + +The `standard` inventory form is provisional while CFR issue [#7](https://github.com/GovStackWorkingGroup/cfr-architecture/issues/7) is under review. Until GovStack approves a registry or profile for recognised standards and their evidence, these entries **MAY** be used during advisory review but **MUST NOT** produce a passing conformance result. An arbitrary HTTPS reference is not proof that a surface implements the named standard. + +`apis` and `noApi` **MUST NOT** coexist. `noApi: true` means that the BB exposes no service interface at all; it **MUST NOT** be used to mean only that no OpenAPI or AsyncAPI file exists. A repository with neither a discoverable contract nor an explicit `noApi` declaration is non-conformant. + +**Example (informative).** A BB with two independently versioned surfaces: + +```yaml +version: 1 +apis: + - type: openapi + path: api/citizen/openapi.yaml + - type: asyncapi + path: api/events/asyncapi.yaml +``` + +**Example (informative).** A standards-defined interface: + +```yaml +version: 1 +apis: + - type: standard + name: OpenID Connect + reference: https://openid.net/specs/openid-connect-core-1_0.html + discovery: /.well-known/openid-configuration +``` + +## 4.6 Functional-requirement traceability + +**[M+R]** Every GovStack requirement in `spec/**/*.md` **MUST** follow the GovStack Requirements Model. Its heading **MUST** use `### # (<level> <mutability> <verification>)`, where level is `REQUIRED`, `RECOMMENDED`, `DRAFT`, or `DEPRECATED`; mutability is `IMMUTABLE`, `EXTENSIBLE`, `REPLACEABLE`, or `INAPPLICABLE`; and verification is `OBSERVABLE` or `AUDITABLE`. The next non-empty line **MUST** contain its canonical `govstack-...#req-<number>` identifier, the two numbers **MUST** match, and body text **MUST** follow any optional `KF:` metadata lines. A child requirement that changes a parent **MUST** identify the parent with `extends` or `replaces` as defined by the GovStack Specification Framework. + +Every BB that declares at least one API **MUST** provide `api/coverage.yaml` with `version: 1`. Its requirement entries **MUST** match the active REQUIRED and RECOMMENDED requirement IDs exactly: no missing or extra IDs. DRAFT and DEPRECATED requirements, and requirements classified INAPPLICABLE, are not active coverage obligations. A repository that uses `noApi: true` under [§4.5](#45-api-surface-inventory) **MUST NOT** contain `api/coverage.yaml`. + +Each coverage entry **MUST** select exactly one disposition and only its compatible companion field: `operation` with a non-empty `operations` list; `message` with a non-empty `messages` list; `external` with an HTTP(S) `reference`; `non-api` with a non-empty `rationale`; or `planned` with an HTTP(S) `issue`. `non-api` means the active requirement is verified outside the service-interface contract; it is not the Requirements Model's formal `INAPPLICABLE` classifier. A disposition-incompatible companion field **MUST NOT** be present. Values in `operations` and `messages` **MUST** be bare operation or message IDs, and those IDs **MUST** be unique across all canonical surfaces declared by the BB. A `planned` disposition records an API gap and **MUST NOT** be interpreted as coverage; the presence of any `planned` entry **MUST** make full conformance fail until the requirement is implemented and its disposition is updated. + +A REQUIRED requirement **MAY** use `non-api` when it remains applicable but its evidence lives outside the service-interface contract, for example in an audit procedure or policy artifact. The rationale **MUST** identify that verification boundary. `non-api` is traceability, not a waiver and not an `INAPPLICABLE` classification. + +**Example (informative).** + +```yaml +version: 1 +requirements: + - id: "govstack-bb-registration-fr#req-1" + disposition: operation + operations: + - createApplication + - id: "govstack-bb-registration-fr#req-2" + disposition: planned + issue: https://github.com/GovStackWorkingGroup/example/issues/42 +``` diff --git a/api-design-guide/part-b/5-url-structure-and-versioning.md b/api-design-guide/part-b/5-url-structure-and-versioning.md new file mode 100644 index 0000000..c3f33a8 --- /dev/null +++ b/api-design-guide/part-b/5-url-structure-and-versioning.md @@ -0,0 +1,59 @@ +--- +description: "Rules governing URL path structure, resource naming, and version placement in the API surface." +--- + +# 5. URL structure and versioning + +{% hint style="info" %} +**Intent.** Paths describe resources, not actions. Versions are visible. The same resource lives at the same path across BBs. + +**Applies to:** OpenAPI surface only (HTTP/REST). +{% endhint %} + +## 5.1 Major version in the path <a href="#51-major-version-in-the-path" id="51-major-version-in-the-path"></a> + +**[M]** A versioned HTTP surface **MUST** expose its major contract version unambiguously. New GovStack resource APIs **SHOULD** place it in the URL path as `/v{N}/...` (for example, `/v1/policies`). A recognised protocol standard may use its own version-negotiation mechanism. The standard unversioned endpoints of [§5.10](#510-standard-unversioned-endpoints) do not carry the API major version. + +## 5.2 Plural noun resources <a href="#52-plural-noun-resources" id="52-plural-noun-resources"></a> + +**[M+R]** Resource paths **SHOULD** use plural nouns (`/policies`, not `/policy`). + +## 5.3 Kebab-case path segments <a href="#53-kebab-case-path-segments" id="53-kebab-case-path-segments"></a> + +**[M]** Multi-word path segments **SHOULD** use kebab-case (`/event-subscriptions`). A surface governed by an external standard keeps that standard's spelling, including the `.well-known` prefix that RFC 8615 fixes. + +## 5.4 Shallow path nesting <a href="#54-shallow-path-nesting" id="54-shallow-path-nesting"></a> + +**[M]** Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources. + +## 5.5 Identifiers as path parameters <a href="#55-identifiers-as-path-parameters" id="55-identifiers-as-path-parameters"></a> + +**[M+R]** Resource identifiers **MUST** be path parameters, not query parameters. (`DELETE /v1/events/{eventId}`, not `DELETE /v1/event?event_id=...`.) + +## 5.6 Query parameter naming <a href="#56-query-parameter-naming" id="56-query-parameter-naming"></a> + +**[M]** Query parameter names **SHOULD** follow the JSON naming convention defined in [§9](../part-c/9-json-conventions-and-naming.md). + +## 5.7 No verbs in CRUD paths <a href="#57-no-verbs-in-crud-paths" id="57-no-verbs-in-crud-paths"></a> + +**[M+R]** Verbs **SHOULD NOT** appear in paths for CRUD operations. (`POST /v1/events`, not `POST /v1/event/new`.) + +## 5.8 Actions as sub-resources <a href="#58-actions-as-sub-resources" id="58-actions-as-sub-resources"></a> + +**[R]** Non-CRUD actions **SHOULD** be expressed as sub-resources: `POST /v1/events/{eventId}/cancel`, `POST /v1/operations/{operationId}/cancel`. + +## 5.9 Unversioned health endpoint <a href="#59-unversioned-health-endpoint" id="59-unversioned-health-endpoint"></a> + +**[M+R]** Each BB **MUST** expose an unversioned operational liveness endpoint at `/health`. Health is carried by the HTTP status: `200` when the service is healthy and able to accept work, `503` when it is temporarily unable to. A consumer **MUST** determine health from the status code; response body fields are informational and **MUST NOT** be required for that determination. The `200` response **MUST** use media type `application/json` and **SHOULD** be minimal; the `503` is an error response and carries the problem envelope of [§11.1](../part-c/11-errors.md#111-rfc-9457-problem-details) like any other. The endpoint **MUST** be cheap and bounded, **MUST NOT** carry citizen authentication, and **MUST NOT** expose system-internal detail such as hostnames, versions, stack traces, or dependency topology. It **MUST NOT** probe an external dependency unless that dependency genuinely determines whether the service can accept work. A separate `/ready` endpoint **MAY** be exposed where readiness and liveness semantics differ, under the same rules. + +**Example (informative).** A `/health` response: + +```json +{ + "description": "health of the registration BB" +} +``` + +## 5.10 Standard unversioned endpoints <a href="#510-standard-unversioned-endpoints" id="510-standard-unversioned-endpoints"></a> + +**[M]** A closed set of endpoints sits outside the versioned business surface, because their location or spelling is fixed by something other than this guide. That set is: well-known URIs under `/.well-known/`, whose location RFC 8615 roots at that exact prefix; the operational endpoints of [§5.9](#59-unversioned-health-endpoint); and a runtime specification-discovery endpoint where a BB serves one, conventionally `/openapi.json` or `/asyncapi.json`. These endpoints are exempt from [§5.1](#51-major-version-in-the-path), [§5.3](#53-kebab-case-path-segments), and the collection rules of [§12](../part-c/12-pagination-filtering-sorting.md), and they satisfy [§13.1](../part-d/13-authentication-and-authorisation.md#131-default-security-on-every-operation) with an explicit empty security requirement (`security: []`) where they are unauthenticated. No other endpoint **MAY** claim this exemption, and a BB **MUST NOT** place a business resource under one of these paths in order to escape the rules above: they are read-only and **MUST NOT** declare `POST`, `PUT`, `PATCH`, or `DELETE`. diff --git a/api-design-guide/part-b/6-http-methods.md b/api-design-guide/part-b/6-http-methods.md new file mode 100644 index 0000000..f9967d9 --- /dev/null +++ b/api-design-guide/part-b/6-http-methods.md @@ -0,0 +1,39 @@ +--- +description: "Rules defining the meaning, safety, and idempotency guarantees of each HTTP method." +--- + +# 6. HTTP methods + +{% hint style="info" %} +**Intent.** Each verb has one meaning. Specs MUST NOT redefine them. + +**Applies to:** OpenAPI surface only (HTTP/REST). +{% endhint %} + +## 6.1 GET is safe and idempotent <a href="#61-get-is-safe-and-idempotent" id="61-get-is-safe-and-idempotent"></a> + +**[M+R]** `GET` **MUST** be safe and idempotent. Requests **MUST NOT** carry a body. + +## 6.2 POST creates or performs actions <a href="#62-post-creates-or-performs-actions" id="62-post-creates-or-performs-actions"></a> + +**[R]** `POST` **MUST** be used to create a server-assigned resource or to perform an action that is not expressed by another HTTP method. A creation completed during the request **MUST** return `201 Created`; work accepted but not completed **MUST** return `202 Accepted` with an Operation resource; a completed non-creation action **MUST** return `200 OK` with a result or `204 No Content` without one. A POST action **MAY** be naturally idempotent or made retry-safe under [§14](../part-d/14-idempotency.md). + +## 6.3 PUT replaces the entire resource <a href="#63-put-replaces-the-entire-resource" id="63-put-replaces-the-entire-resource"></a> + +**[R]** `PUT` **MUST** replace the entire resource and **MUST** be idempotent. + +## 6.4 PATCH uses a registered patch format <a href="#64-patch-uses-a-registered-patch-format" id="64-patch-uses-a-registered-patch-format"></a> + +**[M+R]** `PATCH` partially updates a resource. Its request body **MUST** use a registered patch media type and the operation **MUST** document the selected patch semantics. JSON Merge Patch (RFC 7396) with `application/merge-patch+json` **SHOULD** be the default for simple object updates. Under RFC 7396 a member set to `null` means "remove this member", so Merge Patch cannot set a nullable field ([§9.4](../part-c/9-json-conventions-and-naming.md#94-explicit-nullability)) *to* JSON `null`; it can only remove it. Endpoints where setting a field to `null` must be distinguishable from removing it, or which need element-wise array mutation, **MAY** use RFC 6902 JSON Patch via `application/json-patch+json` or another registered format suited to the contract. + +## 6.5 DELETE response semantics <a href="#65-delete-response-semantics" id="65-delete-response-semantics"></a> + +**[M+R]** `DELETE` removes a resource. Synchronous hard delete **MUST** return `204 No Content` with no body. Soft delete or async delete (audit retention, undo window) **MAY** return `200` with a body describing the resulting state, or `202` with an Operation per [§15](../part-d/15-asynchronous-operations.md). + +## 6.6 POST search for complex queries <a href="#66-post-search-for-complex-queries" id="66-post-search-for-complex-queries"></a> + +**[M+R]** Complex queries that cannot fit in a URL query string **MAY** use `POST /v1/{collection}/search` with a request body. The response **MUST** return `200`, not `201`. + +## 6.7 Bulk mutation needs explicit selection <a href="#67-bulk-mutation-needs-explicit-selection" id="67-bulk-mutation-needs-explicit-selection"></a> + +**[M+R]** A bulk-mutating or bulk-deleting operation (a `PUT`, `PATCH`, or `DELETE` whose target is a collection rather than a single identified resource) **MUST** require at least one explicit selection parameter. An operation that mutates or deletes every record when invoked with no criteria is forbidden. Resources designated append-only (audit logs, event logs, ledgers) **MUST NOT** expose `PUT`, `PATCH`, or `DELETE`. (Mutable audit logs and filter-less bulk update or delete operations that could rewrite or destroy an entire registry have both been observed in existing BB specifications.) diff --git a/api-design-guide/part-b/7-http-status-codes.md b/api-design-guide/part-b/7-http-status-codes.md new file mode 100644 index 0000000..7f628f3 --- /dev/null +++ b/api-design-guide/part-b/7-http-status-codes.md @@ -0,0 +1,95 @@ +--- +description: "Rules mapping API outcomes to standard HTTP status codes, caching, and concurrency headers." +--- + +# 7. HTTP status codes + +{% hint style="info" %} +**Intent.** Status codes are part of the contract. Same outcome, same code, across BBs. + +**Applies to:** OpenAPI surface only (HTTP/REST). +{% endhint %} + +## 7.1 200 for successful reads <a href="#71-200-for-successful-reads" id="71-200-for-successful-reads"></a> + +**[R]** A successful read or completed non-creation action that returns a representation **MUST** use `200 OK`. + +## 7.2 201 Created with Location <a href="#72-201-created-with-location" id="72-201-created-with-location"></a> + +**[M]** A resource creation that completes during the request **MUST** use `201 Created`. The response **MUST** include a `Location` header pointing to the created resource. An operation that has only been accepted for later processing **MUST NOT** return `201`; it **MUST** use `202` under [§7.3](#73-202-accepted-for-async-operations). + +## 7.3 202 Accepted for async operations <a href="#73-202-accepted-for-async-operations" id="73-202-accepted-for-async-operations"></a> + +**[M+R]** Work accepted but not completed during the request **MUST** use `202 Accepted`. The response **MUST** include a `Location` header pointing to an Operation resource and **MUST** return that Operation representation using the local schema defined under [§15](../part-d/15-asynchronous-operations.md). `202` **MUST NOT** claim that the requested work succeeded. + +## 7.4 204 for void responses <a href="#74-204-for-void-responses" id="74-204-for-void-responses"></a> + +**[R]** A successful synchronous DELETE or other successful operation with no response representation **MUST** use `204 No Content` and **MUST NOT** include a response body. + +## 7.5 400 for malformed requests <a href="#75-400-for-malformed-requests" id="75-400-for-malformed-requests"></a> + +**[R]** An operation that accepts path, query, header, or body input **MUST** declare `400 Bad Request` for malformed, unparseable, or structurally invalid input. Well-formed input that violates domain semantics **MUST** use `422` under [§7.11](#711-422-for-semantic-errors). + +## 7.6 401 with WWW-Authenticate <a href="#76-401-with-www-authenticate" id="76-401-with-www-authenticate"></a> + +**[M+R]** Every operation requiring authentication **MUST** declare `401 Unauthorized` for missing or invalid authentication. The response **MUST** include a `WWW-Authenticate` header (RFC 9110). For an OAuth 2.0 bearer scheme ([§13.2](../part-d/13-authentication-and-authorisation.md#132-oauth-and-oidc-for-citizen-operations)), a challenge for an invalid token **SHOULD** carry the RFC 6750 `invalid_token` error; a challenge for a request containing no authentication credentials **SHOULD NOT** include an OAuth error code. + +## 7.7 403 when not authorised <a href="#77-403-when-not-authorised" id="77-403-when-not-authorised"></a> + +**[R]** Every secured operation that can reject an authenticated caller for insufficient permission **MUST** declare `403 Forbidden`. A BB **MAY** return `404` instead when concealing the existence of a forbidden resource is part of its documented security contract, as allowed by RFC 9110. + +## 7.8 404 for missing resources <a href="#78-404-for-missing-resources" id="78-404-for-missing-resources"></a> + +**[R]** Every operation that addresses a specific resource **MUST** declare `404 Not Found` for an absent resource or for a resource whose existence is intentionally concealed under [§7.7](#77-403-when-not-authorised). An empty collection **MUST** return `200` with an empty `items` array, not `404`. + +## 7.9 409 for state conflicts <a href="#79-409-for-state-conflicts" id="79-409-for-state-conflicts"></a> + +**[R]** An operation that creates a uniquely keyed resource, performs a state transition, or supports idempotent retry **MUST** declare `409 Conflict` when it can conflict with current resource or processing state, including an illegal transition or concurrent in-flight retry under [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch). A failed conditional request precondition **MUST** use `412` ([§7.15](#715-412-for-failed-preconditions)), not `409`. + +## 7.10 410 for permanent removal <a href="#710-410-for-permanent-removal" id="710-410-for-permanent-removal"></a> + +**[R]** A server **MUST** use `410 Gone` only when it knows that a resource or endpoint has been permanently removed; otherwise it **MUST** use `404`. + +## 7.11 422 for semantic errors <a href="#711-422-for-semantic-errors" id="711-422-for-semantic-errors"></a> + +**[R]** A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly "Unprocessable Entity"). The idempotency-fingerprint use of `422` is in [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch). + +## 7.12 429 for rate limits <a href="#712-429-for-rate-limits" id="712-429-for-rate-limits"></a> + +**[R]** An operation that enforces a caller-visible rate limit **MUST** declare `429 Too Many Requests` and the headers required by [§8.7](../part-b/8-headers.md#87-rate-limit-headers-declared). + +## 7.13 Server errors documented <a href="#713-server-errors-documented" id="713-server-errors-documented"></a> + +**[M]** Every operation **MUST** declare `500 Internal Server Error`. Operations exposed through a gateway or dependent service **SHOULD** additionally declare the applicable `502`, `503`, and `504` responses. + +## 7.14 All status codes declared <a href="#714-all-status-codes-declared" id="714-all-status-codes-declared"></a> + +**[M]** Every operation **MUST** declare the status codes it can return. Declaring only `200` is forbidden. + +## 7.15 412 for failed preconditions <a href="#715-412-for-failed-preconditions" id="715-412-for-failed-preconditions"></a> + +**[R]** A failed conditional request precondition such as `If-Match` **MUST** use `412 Precondition Failed`. + +## 7.16 ETag and If-None-Match <a href="#716-etag-and-if-none-match" id="716-etag-and-if-none-match"></a> + +**[M+R]** Endpoints that return resources **SHOULD** advertise an `ETag` response header derived from the selected representation. `GET` clients **MAY** send `If-None-Match` to receive `304 Not Modified` on no change. An endpoint using ETags for write concurrency **MUST** provide a strong validator suitable for the strong comparison required by `If-Match`. + +## 7.17 Optimistic concurrency with If-Match <a href="#717-optimistic-concurrency-with-if-match" id="717-optimistic-concurrency-with-if-match"></a> + +**[M+R]** `PUT` and `PATCH` endpoints **SHOULD** support optimistic concurrency: clients send `If-Match: <strong-etag>` and the server returns `412 Precondition Failed` ([§7.15](#715-412-for-failed-preconditions)) if the resource has changed. An endpoint that requires a conditional write **SHOULD** return `428 Precondition Required` when `If-Match` is absent. A failed `If-Match` precondition **MUST** use `412`, not `409`; `409` ([§7.9](#79-409-for-state-conflicts)) is reserved for conflicts not expressed by a conditional precondition. + +## 7.18 405 with Allow header <a href="#718-405-with-allow-header" id="718-405-with-allow-header"></a> + +**[M]** `405 Method Not Allowed`: the target resource does not support the request method. The response **MUST** include an `Allow` header listing the supported methods (RFC 9110). + +## 7.19 415 for unsupported media types <a href="#719-415-for-unsupported-media-types" id="719-415-for-unsupported-media-types"></a> + +**[M+R]** `415 Unsupported Media Type`: the request payload media type is not supported. PATCH endpoints ([§6.4](../part-b/6-http-methods.md#64-patch-uses-a-registered-patch-format)) **MUST** return `415` when the request does not use one of the registered patch media types documented by that operation. `406 Not Acceptable` **MAY** be returned when no representation matches the request `Accept` header. + +## 7.20 No-store on error responses <a href="#720-no-store-on-error-responses" id="720-no-store-on-error-responses"></a> + +**[M]** Error responses (`application/problem+json`) and Operation-status responses ([§15](../part-d/15-asynchronous-operations.md)) **SHOULD** declare `Cache-Control: no-store`, so a shared cache cannot replay a transient failure or stale operation state. Broader caching behaviour is operational and out of scope ([§1.2](../1-introduction.md#12-scope)). + +## 7.21 Schemas for successful response bodies <a href="#721-schemas-for-successful-response-bodies" id="721-schemas-for-successful-response-bodies"></a> + +**[M]** Every declared `2xx` response that carries a body **MUST** declare at least one concrete media type and a response schema for each declared media type. An empty schema or description without a schema **MUST NOT** stand in for a response contract. Responses whose HTTP semantics prohibit a body, including `204`, **MUST NOT** declare response content. diff --git a/api-design-guide/part-b/8-headers.md b/api-design-guide/part-b/8-headers.md new file mode 100644 index 0000000..489d2d5 --- /dev/null +++ b/api-design-guide/part-b/8-headers.md @@ -0,0 +1,39 @@ +--- +description: "Rules governing standard, custom, and rate-limit HTTP headers used across the API surface." +--- + +# 8. Headers + +{% hint style="info" %} +**Intent.** Standard headers are reused. Custom headers are namespaced. No essential data lives in the URL. + +**Applies to:** OpenAPI surface only (HTTP/REST). The [§8.6](#86-no-personal-data-in-addressable-locations) principle (no personal data in addressable locations) is universal; its AsyncAPI restatement for channel names, topics, routing keys, and message headers is in [§17](../part-d/17-asyncapi-channel-rules.md). +{% endhint %} + +## 8.1 Credentials in Authorization header <a href="#81-credentials-in-authorization-header" id="81-credentials-in-authorization-header"></a> + +**[M+R]** Authentication credentials **MUST** travel in the `Authorization` header. They **MUST NOT** appear in query parameters, fragments, or URL paths. + +## 8.2 Accept-Language and Content-Language <a href="#82-accept-language-and-content-language" id="82-accept-language-and-content-language"></a> + +**[M+R]** Localisation requests **MUST** use `Accept-Language`; a localised response **MUST** identify the language actually selected using `Content-Language`, which is not necessarily the request's first preference. A cacheable response selected using `Accept-Language` **MUST** declare `Vary: Accept-Language`. + +## 8.3 Idempotency-Key header accepted <a href="#83-idempotency-key-header-accepted" id="83-idempotency-key-header-accepted"></a> + +**[M+R]** POST endpoints that require idempotency under [§14](../part-d/14-idempotency.md) **MUST** accept an `Idempotency-Key` header, unless [§14.6](../part-d/14-idempotency.md#146-naturally-idempotent-designs) applies. + +## 8.4 W3C Trace Context correlation <a href="#84-w3c-trace-context-correlation" id="84-w3c-trace-context-correlation"></a> + +**[M+R]** Every cross-service HTTP operation **MUST** declare the W3C Trace Context `traceparent` request header and **MAY** declare `tracestate`. A conforming implementation **MUST** propagate a valid received trace context on downstream calls and **MUST** create a valid new context when none is present or the received value is invalid. `tracestate` **MUST NOT** contain personal data. The RFC 9457 `traceId` extension in [§11.3](../part-c/11-errors.md#113-trace-identifier) **MUST** equal the 32-hex-digit trace-id component of the request's effective `traceparent`. A separate business or support correlation identifier **MAY** be defined, but **MUST NOT** replace Trace Context. + +## 8.5 No new X- prefixed headers <a href="#85-no-new-x--prefixed-headers" id="85-no-new-x--prefixed-headers"></a> + +**[M]** New custom headers introduced by this guide or by BBs **MUST NOT** use the `X-` prefix, per RFC 6648. Existing private `X-` headers **MAY** remain only on an unchanged legacy major version and **MUST NOT** be introduced on a new surface or new major version. + +## 8.6 No personal data in addressable locations <a href="#86-no-personal-data-in-addressable-locations" id="86-no-personal-data-in-addressable-locations"></a> + +**[R]** Personal data (national identifier, phone, email, name, date of birth, exact address) **MUST NOT** appear in path segments, query parameters, or header values other than purpose-specific signed assertions (e.g., an OIDC ID token). Opaque server-generated IDs ([§10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)) **MUST** be used to refer to citizen records in URLs. + +## 8.7 Rate-limit headers declared <a href="#87-rate-limit-headers-declared" id="87-rate-limit-headers-declared"></a> + +**[M+R]** An endpoint rate-limited by the BB itself **MUST** document the quota scope and **MUST** declare the `RateLimit` response header using the Structured Field syntax pinned from `draft-ietf-httpapi-ratelimit-headers-11`. A BB that advertises quota-policy details **MUST** use `RateLimit-Policy`. The server **MAY** omit these advisory headers on individual responses as allowed by the draft, but a `429` response **MUST** declare `Retry-After`; when `Retry-After` and `RateLimit` are both present, clients **MUST** treat `Retry-After` as authoritative. Where an API gateway or interoperability mediator owns rate limiting, the BB specification **MUST** state that fact instead of claiming to emit headers it does not control. The legacy `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` fields **MUST NOT** be described as conforming to the pinned draft. diff --git a/api-design-guide/part-c/10-data-types-and-formats.md b/api-design-guide/part-c/10-data-types-and-formats.md new file mode 100644 index 0000000..eaa67d9 --- /dev/null +++ b/api-design-guide/part-c/10-data-types-and-formats.md @@ -0,0 +1,55 @@ +--- +description: "Canonical representations for identifiers, dates, money, phone numbers, emails, binaries, and standardised code fields shared across building blocks." +--- + +# 10. Data types and formats + +{% hint style="info" %} +**Intent.** A given concept has the same representation across BBs. + +**Applies to:** Universal. +{% endhint %} + +## 10.1 Opaque server-generated identifiers <a href="#101-opaque-server-generated-identifiers" id="101-opaque-server-generated-identifiers"></a> + +**[M+R]** Resource identifiers **MUST** be opaque, URL-safe strings, server-generated, and globally unique within the BB. UUID v4 (`format: uuid`) **SHOULD** be the default; ULID, KSUID, or other opaque IDs **MAY** be used where ordering or sortability matters. Clients **MUST** treat all IDs as opaque. **Exception:** registries with statutory identifiers (civil registry numbers, parcel IDs, business numbers, licence numbers) **MAY** use those identifiers in URL paths provided [§8.6](../part-b/8-headers.md#86-no-personal-data-in-addressable-locations) is satisfied (the identifier does not constitute personal data; a parcel number or business number is acceptable, a national ID or passport number is not). The opacity requirement applies to BB-generated identifiers; statutory identifiers are by definition not opaque to clients. + +## 10.2 RFC 3339 timestamps <a href="#102-rfc-3339-timestamps" id="102-rfc-3339-timestamps"></a> + +**[M]** Timestamps **MUST** be RFC 3339 in UTC, serialized with the `Z` designator and declared as `format: date-time`. A non-UTC offset **MUST NOT** be used in an API payload; where a local time zone is significant to the consumer, it is carried in a separate field alongside the UTC value. UTC-only is inherited from `govstack-cfr-data#req-2` and cannot be relaxed by a BB specification. + +## 10.3 RFC 3339 calendar dates <a href="#103-rfc-3339-calendar-dates" id="103-rfc-3339-calendar-dates"></a> + +**[M]** Dates without time **MUST** be RFC 3339 calendar dates, declared as `format: date`. + +## 10.4 Decimal-string monetary amounts <a href="#104-decimal-string-monetary-amounts" id="104-decimal-string-monetary-amounts"></a> + +**[M+R]** Monetary amounts **MUST** use the object `{ amount: string (decimal), currency: string (ISO 4217) }`. Floats **MUST NOT** be used for money. Decimal-string is chosen over minor-units because GovStack-adopting countries may include currencies with non-decimal subunits and zero-subunit currencies, which a minor-units convention handles inconsistently. + +## 10.5 E.164 phone numbers <a href="#105-e164-phone-numbers" id="105-e164-phone-numbers"></a> + +**[M+R]** Phone numbers **MUST** be E.164 strings. + +## 10.6 RFC 5322 email addresses <a href="#106-rfc-5322-email-addresses" id="106-rfc-5322-email-addresses"></a> + +**[M+R]** Email addresses **MUST** be RFC 5322 strings, declared as `format: email`. + +## 10.7 Binary uploads and base64 payloads <a href="#107-binary-uploads-and-base64-payloads" id="107-binary-uploads-and-base64-payloads"></a> + +**[M+R]** Large binary uploads **MUST** use `multipart/form-data` or a dedicated binary endpoint. Small inline payloads (signatures, certificates, QR codes, attestations) **MAY** be base64-encoded in JSON bodies; the field **MUST** then be declared with `contentEncoding: base64` and a documented size limit. + +## 10.8 ISO 3166-1 country codes <a href="#108-iso-3166-1-country-codes" id="108-iso-3166-1-country-codes"></a> + +**[M+R]** Country codes **MUST** be ISO 3166-1 alpha-2. + +## 10.9 BCP 47 language codes <a href="#109-bcp-47-language-codes" id="109-bcp-47-language-codes"></a> + +**[M+R]** Language codes **MUST** be BCP 47. + +## 10.10 ISO 4217 currency codes <a href="#1010-iso-4217-currency-codes" id="1010-iso-4217-currency-codes"></a> + +**[M+R]** Currency codes **MUST** be ISO 4217. + +## 10.11 UTF-8 text encoding <a href="#1011-utf-8-text-encoding" id="1011-utf-8-text-encoding"></a> + +**[M]** Text in API payloads **MUST** be UTF-8. A media type declared anywhere in the specification **MUST NOT** carry a `charset` parameter naming any other encoding. `charset=utf-8` **MAY** be stated explicitly, though it is redundant on JSON media types, whose encoding RFC 8259 already fixes at UTF-8. This is inherited from `govstack-cfr-data#req-1` and cannot be relaxed by a BB specification. diff --git a/api-design-guide/part-c/11-errors.md b/api-design-guide/part-c/11-errors.md new file mode 100644 index 0000000..f4463ac --- /dev/null +++ b/api-design-guide/part-c/11-errors.md @@ -0,0 +1,60 @@ +--- +description: "One RFC 9457 HTTP problem model with stable type URIs, trace correlation, field-level validation, and a separate transport-neutral asynchronous error model." +--- + +# 11. Errors + +{% hint style="info" %} +**Intent.** One HTTP error format ecosystem-wide. The RFC 9457 `type` URI is the machine identifier, so clients do not have to reconcile a second error-code field. + +**Applies to:** Universal. RFC 9457 and its `status` member apply only to HTTP responses. AsyncAPI rejection and failure messages keep the separate transport-neutral shape in [§11.6](#116-transport-neutral-asynchronous-errors). +{% endhint %} + +## 11.1 RFC 9457 problem details <a href="#111-rfc-9457-problem-details" id="111-rfc-9457-problem-details"></a> + +**[M]** HTTP `4xx` and `5xx` responses **MUST** use media type `application/problem+json` and the RFC 9457 Problem Details model (RFC 9457 obsoletes RFC 7807 and retains this media type). This rule **MUST NOT** be represented as an RFC 9457 requirement on a non-HTTP message; [§11.6](#116-transport-neutral-asynchronous-errors) defines the separate asynchronous model. + +## 11.2 Stable HTTP problem type URI <a href="#112-stable-http-problem-type-uri" id="112-stable-http-problem-type-uri"></a> + +**[M+R]** Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be the sole machine identifier for the problem and **MUST** use `https://govstack.global/problems/{bb-code}/{problem-slug}`. `{bb-code}` is the registered code from [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code); `{problem-slug}` **MUST** be stable kebab-case, for example `bad-request`, `invalid-field`, or `internal-error`. The URI **SHOULD** dereference to human-readable documentation. A GovStack HTTP Problem **MUST NOT** add a duplicate machine identifier such as `code`. + +`status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments. HTTP problems **MUST NOT** add a `timestamp`; use response metadata and trace correlation for occurrence diagnostics. + +## 11.3 Trace identifier <a href="#113-trace-identifier" id="113-trace-identifier"></a> + +**[M]** Every GovStack HTTP problem **MUST** include `traceId`, containing the W3C trace-id defined by [§8.4](../part-b/8-headers.md#84-w3c-trace-context-correlation). + +## 11.4 Field-level errors array <a href="#114-field-level-errors-array" id="114-field-level-errors-array"></a> + +**[M+R]** Where an HTTP failure is attributable to specific request fields, those field-level validation errors **MUST** appear in an `errors` array; each entry **MUST** contain `pointer` (JSON Pointer) and `message`. It **MUST NOT** add a field-level `code`; the enclosing Problem `type` identifies the problem class. The `errors` array is omitted for failures not attributable to a field (for example, an idempotency-key fingerprint mismatch, [§14.5](../part-d/14-idempotency.md#145-key-reuse-and-fingerprint-mismatch)). + +**Example (informative).** A `422` validation failure carrying the RFC 9457 fields, `traceId`, and the field-level `errors` array: + +```json +{ + "type": "https://govstack.global/problems/registration/invalid-field", + "title": "Request validation failed", + "status": 422, + "detail": "Two request fields failed validation.", + "instance": "/v1/applications/3f6c0e63-9f7e-4d51-a3ce-58b2c7d0f3a1", + "traceId": "6f1c3f0e2a9b4c8d7e6f5a4b3c2d1e0f", + "errors": [ + { + "pointer": "/applicant/phoneNumber", + "message": "Phone number must be an E.164 string." + }, + { + "pointer": "/applicant/birthDate", + "message": "Date must be an RFC 3339 calendar date." + } + ] +} +``` + +## 11.5 Stable HTTP problem fields across languages <a href="#115-stable-http-problem-fields-across-languages" id="115-stable-http-problem-fields-across-languages"></a> + +**[R]** HTTP Problem `title`, `detail`, and field-error `message` **MAY** be localised. The `type` URI, `status`, `traceId`, `instance`, and field-error `pointer` **MUST NOT** be translated. + +## 11.6 Transport-neutral asynchronous errors <a href="#116-transport-neutral-asynchronous-errors" id="116-transport-neutral-asynchronous-errors"></a> + +**[M+R]** An asynchronous command rejection or processing failure **MUST** use the shared `GovStackAsyncError` schema from `govstack-asyncapi-common.yaml`, with message `contentType: application/json`. This transport-neutral schema remains separate from the HTTP Problem model and **MUST** contain `type` (a stable absolute problem-type URI), `title`, `code`, `traceId`, and `timestamp`; it **MAY** contain `detail` and `errors`. Each asynchronous field error contains `pointer`, `code`, and `message` as defined by the shared `AsyncFieldError` schema. When carried as a structured CloudEvent, this object **MUST** be the event `data`. It **MUST NOT** contain RFC 9457 `status` merely to simulate an HTTP response; a protocol-specific rejection code **MUST** be declared in the applicable binding or as a separately named field whose semantics the BB defines. diff --git a/api-design-guide/part-c/12-pagination-filtering-sorting.md b/api-design-guide/part-c/12-pagination-filtering-sorting.md new file mode 100644 index 0000000..9fa0f79 --- /dev/null +++ b/api-design-guide/part-c/12-pagination-filtering-sorting.md @@ -0,0 +1,78 @@ +--- +description: "Mandatory pagination for collections, cursor and offset envelopes, page-size bounds, and conventions for sorting and filtering." +--- + +# 12. Pagination, filtering, sorting + +{% hint style="info" %} +**Intent.** Collection endpoints survive production data volumes. + +**Applies to:** OpenAPI surface only (HTTP/REST). AsyncAPI streams use transport-specific backpressure and replay primitives; the declaration requirements for those contracts are in [§17](../part-d/17-asyncapi-channel-rules.md). +{% endhint %} + +## 12.1 Collections must paginate <a href="#121-collections-must-paginate" id="121-collections-must-paginate"></a> + +**[M+R]** Endpoints returning collections **MUST** paginate. Unbounded responses are forbidden. A collection whose size is fixed by the specification itself **MAY** be returned unpaginated, provided the bound is declared in the schema with `maxItems`; an undeclared expectation that a collection stays small does not qualify, because an integrator cannot see it and a linter cannot check it. The standard unversioned endpoints of [§5.10](../part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints) are not collections and this section does not apply to them. + +## 12.2 Cursor pagination by default <a href="#122-cursor-pagination-by-default" id="122-cursor-pagination-by-default"></a> + +**[M+R]** Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with optional query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken`. A cursor **MUST** be URL-safe, opaque, and integrity-protected; base64 encoding of a transparent internal value is not sufficient. It **MUST NOT** contain personal data, grant authority, or bypass authorization on a later request. Clients **MUST NOT** parse or construct cursor values, and servers **MUST** re-authorize every page request. Except for `pageSize`, the filter and sort arguments on a follow-up request **MUST** equal those that produced the cursor; a mismatch, malformed cursor, or expired cursor **MUST** return `400` with a stable Problem `type`. The specification **MUST** document cursor expiry and a deterministic default order with a unique tie-breaker so concurrent records do not create ambiguous page boundaries. + +## 12.3 Cursor pagination envelope <a href="#123-cursor-pagination-envelope" id="123-cursor-pagination-envelope"></a> + +**[M]** The cursor-pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, total? } }`. `nextCursor` **MUST** be a non-empty string when another page is available and **MUST** be `null` on the final page; its schema therefore **MUST** declare explicit nullability. Clients determine whether another page is available from `nextCursor` and no separate `hasMore` field is used. `total`, when present, **MUST** state whether it is exact or estimated and whether it reflects the first-page snapshot or the current collection. The `pageInfo` wrapper is inspired by GraphQL Relay Connections but deliberately uses flat `items` and one continuation field. + +**Example (informative).** A cursor-paginated collection response (`total` omitted per [§12.5](#125-optional-total-count)): + +```json +{ + "items": [ + { "id": "0d4b2a4e-3f5d-4a83-9b0e-6f2e8d1c7a90", "status": "ACTIVE" }, + { "id": "8a1f9c2b-7e64-4f0d-8a3b-2c5d9e0f1b47", "status": "PENDING_REVIEW" } + ], + "pageInfo": { + "nextCursor": "pgn_7JpQ9m2W4xK8fR3cT6vN1" + } +} +``` + +## 12.4 Documented pageSize bounds <a href="#124-documented-pagesize-bounds" id="124-documented-pagesize-bounds"></a> + +**[M+R]** `pageSize` **MUST** have a documented default and maximum. Specific numeric values are per-BB. + +## 12.5 Optional total count <a href="#125-optional-total-count" id="125-optional-total-count"></a> + +**[R]** `total` **MAY** be omitted when computing it is expensive. + +## 12.6 Offset pagination envelope <a href="#126-offset-pagination-envelope" id="126-offset-pagination-envelope"></a> + +**[M+R]** Offset pagination **MAY** be used for admin or fixed-size lists. In that case the envelope **MUST** be the flat shape `{ items, offset, limit, total }`, distinct from the cursor `pageInfo` envelope in [§12.3](#123-cursor-pagination-envelope); `total` is required here (unlike [§12.3](#123-cursor-pagination-envelope), where it is optional). + +**Example (informative).** An offset-paginated response for an admin list (`total` required here): + +```json +{ + "items": [ + { "id": "b7f3d9e0-1a2b-4c5d-8e9f-0a1b2c3d4e5f", "name": "Civil registration office 12" } + ], + "offset": 0, + "limit": 20, + "total": 134 +} +``` + +## 12.7 Sort parameter convention <a href="#127-sort-parameter-convention" id="127-sort-parameter-convention"></a> + +**[M]** An API that supports sorting **MUST** document the parameter, allowed fields, direction syntax, default order, and a stable tie-breaker. The GovStack default **SHOULD** be `sort`, with `field` for ascending, `-field` for descending, and commas between multiple criteria. + +## 12.8 Simple equality filtering <a href="#128-simple-equality-filtering" id="128-simple-equality-filtering"></a> + +**[M+R]** Simple equality filtering on non-personal, non-secret fields **SHOULD** use one query parameter per field. A filter containing personal data or another value prohibited from URLs by [§8.6](../part-b/8-headers.md#86-no-personal-data-in-addressable-locations) **MUST NOT** use a query parameter and **MUST** use a documented body-based search contract such as [§12.9](#129-complex-filtering-via-search). + +## 12.9 Complex filtering via search <a href="#129-complex-filtering-via-search" id="129-complex-filtering-via-search"></a> + +**[M+R]** Complex filtering and any filtering that contains personal data **SHOULD** use a request body, conventionally at `POST /v1/{collection}/search` per [§6.6](../part-b/6-http-methods.md#66-post-search-for-complex-queries). When a body-based search is used, pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the [§12.3](#123-cursor-pagination-envelope) envelope. A follow-up request **MUST** retain the same search criteria and sort values as the request that produced its cursor. + +## 12.10 Sparse fieldsets out of scope <a href="#1210-sparse-fieldsets-out-of-scope" id="1210-sparse-fieldsets-out-of-scope"></a> + +Sparse fieldsets (response field selection) are out of scope for v1.0. diff --git a/api-design-guide/part-c/9-json-conventions-and-naming.md b/api-design-guide/part-c/9-json-conventions-and-naming.md new file mode 100644 index 0000000..cb5f94c --- /dev/null +++ b/api-design-guide/part-c/9-json-conventions-and-naming.md @@ -0,0 +1,71 @@ +--- +description: "Field naming, JSON representation, forward-compatibility, and specification-extension conventions applied across every building block." +--- + +# 9. JSON conventions and naming + +{% hint style="info" %} +**Intent.** One naming style ecosystem-wide. Existing BB specifications mix camelCase, PascalCase, snake_case, and fields with literal spaces, sometimes within a single BB. + +**Applies to:** Universal. +{% endhint %} + +## 9.1 JSON as default media type <a href="#91-json-as-default-media-type" id="91-json-as-default-media-type"></a> + +**[M+R]** Default response media type **MUST** be `application/json` unless the resource is binary or a document export. + +## 9.2 camelCase field names <a href="#92-camelcase-field-names" id="92-camelcase-field-names"></a> + +**[M]** GovStack-owned JSON field names **SHOULD** use `camelCase` consistently within a surface. Fields adopted from an external standard retain that standard's spelling. (See [note below](#note-on-92) on the choice of casing.) + +## 9.3 Real JSON booleans <a href="#93-real-json-booleans" id="93-real-json-booleans"></a> + +**[M]** Boolean fields **MUST** be JSON booleans (`true`/`false`), not strings. + +## 9.4 Explicit nullability <a href="#94-explicit-nullability" id="94-explicit-nullability"></a> + +**[M]** Nullability **MUST** be explicit (`type: [..., "null"]` per OpenAPI 3.1). + +## 9.5 No spaces or non-ASCII names <a href="#95-no-spaces-or-non-ascii-names" id="95-no-spaces-or-non-ascii-names"></a> + +**[M]** GovStack-owned field names **SHOULD** use ASCII identifiers without spaces. Fields adopted from an external standard retain that standard's spelling. + +## 9.6 Avoid abbreviations <a href="#96-avoid-abbreviations" id="96-avoid-abbreviations"></a> + +**[R]** Abbreviations **SHOULD NOT** be used (prefer `quantity` over `qty`). + +## 9.7 Screaming snake case enum values <a href="#97-screaming-snake-case-enum-values" id="97-screaming-snake-case-enum-values"></a> + +**[M]** Enum values that name a BB-defined state or category **SHOULD** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (event types per [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), sort keys per [§12.7](../part-c/12-pagination-filtering-sorting.md#127-sort-parameter-convention)), codes drawn from an external standard (BCP 47 language tags per [§10.9](../part-c/10-data-types-and-formats.md#109-bcp-47-language-codes), ISO 4217 currency codes per [§10.10](../part-c/10-data-types-and-formats.md#1010-iso-4217-currency-codes)), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types. + +## 9.8 Forward-compatible schemas <a href="#98-forward-compatible-schemas" id="98-forward-compatible-schemas"></a> + +**[M]** Schemas **MUST** be designed for forward-compatibility: unknown fields and unknown enum values **MUST** be safely ignorable by conforming clients. Schemas **MUST NOT** rely on `additionalProperties: false` at the top level of resource bodies, since that prevents adding fields without a breaking change. Conforming client behaviour (ignoring unknowns) is documented in [§18.6](../part-d/18-compatibility-and-lifecycle.md#186-clients-ignore-unknown-fields) as a non-normative reader expectation. + +## 9.9 No closed enums for growing sets <a href="#99-no-closed-enums-for-growing-sets" id="99-no-closed-enums-for-growing-sets"></a> + +**[R]** Fields whose value set is expected to grow **MUST NOT** be declared as a closed OpenAPI `enum`, because client code generated from a closed enum typically rejects values added later, which would make the "adding enum values is non-breaking" guarantee of [§18.3](../part-d/18-compatibility-and-lifecycle.md#183-backward-compatible-minor-changes) false in practice. Such fields **MUST** either be declared as an open `type: string` annotated with `x-extensible-enum` (carrying the known values), or define an explicit fallback member (e.g., `UNKNOWN`) that conforming clients map unrecognised values to. Truly fixed value sets (e.g., ISO-defined codes) **MAY** remain closed enums. + +## 9.10 GovStack extension prefix <a href="#910-govstack-extension-prefix" id="910-govstack-extension-prefix"></a> + +**[M+R]** GovStack-defined specification extensions on OpenAPI or AsyncAPI documents **MUST** be prefixed `x-govstack-` (for example, `x-govstack-deprecated` in [§18.7](../part-d/18-compatibility-and-lifecycle.md#187-asyncapi-deprecation-metadata), and `x-govstack-api-guide` in [§20.3](../part-e/20-conformance-and-validation.md#203-declared-guide-conformance-version)). A prefix rule does not create or standardise an extension; each GovStack extension still requires an explicit schema and governing rule. + +## 9.11 Single registered BB code <a href="#911-single-registered-bb-code" id="911-single-registered-bb-code"></a> + +**[M+R]** Every namespace that embeds a BB code (HTTP problem-type URLs [§11.2](../part-c/11-errors.md#112-stable-http-problem-type-uri), OAuth scopes [§13.4](../part-d/13-authentication-and-authorisation.md#134-namespaced-oauth-scopes), event types [§16.3](../part-d/16-cloudevents-and-webhooks.md#163-reverse-dns-event-types), logical channel IDs [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses), and any transport-neutral asynchronous error code that embeds one [§11.6](../part-c/11-errors.md#116-transport-neutral-asynchronous-errors)) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. Until GovStack publishes a canonical register, codes **SHOULD** be agreed through the API Working Group. + +## Note on 9.2 <a href="#note-on-92" id="note-on-92"></a> + +`camelCase` aligns with OAuth/OIDC, OpenID Federation, JSON:API, and the majority of public REST APIs. `snake_case` would align with the Python ecosystem. Either is defensible; consistency across BBs is what matters most. Existing published surfaces follow the compatibility rules in [§18](../part-d/18-compatibility-and-lifecycle.md) rather than renaming fields in place. + +## Carve-out from 9.2 <a href="#carve-out-from-92" id="carve-out-from-92"></a> + +(Per [§1.7](../1-introduction.md#17-precedence-of-external-standards).) + +Fields imported wholesale from an external standard retain that standard's naming. The known cases are RFC 9457 error fields (`type`, `title`, `status`, `detail`, `instance`; see [§11.1](../part-c/11-errors.md#111-rfc-9457-problem-details)) and CloudEvents fields (`specversion`, `id`, `source`, `type`, `time`, `datacontenttype`, `data`; see [§16.2](../part-d/16-cloudevents-and-webhooks.md#162-cloudevents-envelope-required)). CloudEvents extension attributes also follow CloudEvents naming rules, not [§9.2](#92-camelcase-field-names), because CloudEvents requires lowercase ASCII attribute names. The carve-out applies only to fields defined by the imported standard or by a CloudEvents extension. The HTTP `traceId` extension in [§11.3](../part-c/11-errors.md#113-trace-identifier), transport-neutral asynchronous error fields in [§11.6](../part-c/11-errors.md#116-transport-neutral-asynchronous-errors), GovStack-owned transport/application headers, and the contents of event `data` follow [§9.2](#92-camelcase-field-names). + +## Carve-out from 9.7 <a href="#carve-out-from-97" id="carve-out-from-97"></a> + +(Per [§1.7](../1-introduction.md#17-precedence-of-external-standards).) + +Enum values defined by an external standard retain that standard's casing. The known cases are values drawn from an IANA registry: JOSE and COSE algorithm and curve names (`ES256`, `EdDSA`, `Ed25519`, `P-256`) where a BB declares the algorithms it accepts, and media types. diff --git a/api-design-guide/part-d/13-authentication-and-authorisation.md b/api-design-guide/part-d/13-authentication-and-authorisation.md new file mode 100644 index 0000000..68d4014 --- /dev/null +++ b/api-design-guide/part-d/13-authentication-and-authorisation.md @@ -0,0 +1,43 @@ +--- +description: "Rules for how BB API specs declare security schemes, OAuth scopes, and the credential channel for authentication and authorisation." +--- + +# 13. Authentication and authorisation + +{% hint style="info" %} +**Intent.** Every BB declares its security model in the same way, so integrators see a uniform shape for "how do I call this BB" across the catalogue. + +**Applies to:** Universal. Rules reference OpenAPI `securitySchemes`; AsyncAPI 3.0 uses corresponding declarations under `components.securitySchemes` and operation/server `security` (for example, `oauth2`, `openIdConnect`, and `X509`). +{% endhint %} + +## 13.1 Default security on every operation <a href="#131-default-security-on-every-operation" id="131-default-security-on-every-operation"></a> + +**[M]** Each BB API spec **MUST** declare a security scheme block and apply it by default to every operation. On the OpenAPI surface this is a root-level `security` requirement referencing schemes under `components.securitySchemes`. AsyncAPI 3.0 has no root-level `security`: schemes are declared under `components.securitySchemes` and applied on the `servers` and `operations` objects, which together **MUST** cover every operation. Per-operation overrides **MUST** be explicit. The standard unversioned endpoints of [§5.10](../part-b/5-url-structure-and-versioning.md#510-standard-unversioned-endpoints) satisfy this rule with an explicit empty security requirement (`security: []`) where they are unauthenticated: they are exempt from carrying a scheme, not from declaring what they carry. + +## 13.2 OAuth and OIDC for citizen operations <a href="#132-oauth-and-oidc-for-citizen-operations" id="132-oauth-and-oidc-for-citizen-operations"></a> + +**[M+R]** Citizen-facing protected operations **MUST** declare OAuth 2.0 authorization backed by an OpenID Connect provider. On OpenAPI this is either `type: openIdConnect` with `openIdConnectUrl`, or `type: oauth2` with an `authorizationCode` flow. An API that is purely a resource server, validating access tokens issued by an authorization server it does not own and whose endpoints are not part of its own contract, **MAY** instead declare `type: http` with `scheme: bearer` and `bearerFormat: JWT`, because declaring an `oauth2` flow it does not operate would misdescribe the deployed API. That declaration **MUST** document, in the scheme description or an adjacent `/.well-known/` metadata document, the issuers it accepts and the audience value it requires, so an integrator can still discover how to obtain a usable token. The security-scheme description **MUST** state that the API accepts access tokens and **MUST NOT** treat an OIDC ID Token as an API access token. Authorization-code clients **MUST** use PKCE with `S256` as required by the RFC 9700 security baseline; the resource-owner password grant **MUST NOT** be declared, and the implicit grant **MUST NOT** be declared for a new surface. Reference specifications not tied to a live provider **MAY** use a reserved documentation-domain discovery URL; adopter-specific discovery, authorisation, token, and JWKS endpoints belong in implementation profiles. + +## 13.3 Distinct scheme for BB-to-BB calls <a href="#133-distinct-scheme-for-bb-to-bb-calls" id="133-distinct-scheme-for-bb-to-bb-calls"></a> + +**[M+R]** BB-to-BB operations crossing a service-to-service trust boundary, whether routed directly or through an interoperability mediator, **MUST** declare a distinct security scheme appropriate for service-to-service authentication. On OpenAPI this **MUST** be `type: mutualTLS` or an OAuth client-credentials scheme using confidential-client authentication; on AsyncAPI it **MUST** be `type: X509`, OAuth client credentials, or a documented protocol-specific scheme such as SASL. OAuth access tokens **MUST** be audience-restricted to the intended BB and scope-restricted to the operation. Sender-constrained access tokens under RFC 8705 or RFC 9449 **SHOULD** be used across an inter-BB trust boundary. The spec **MUST** distinguish citizen-facing from inter-BB operations. + +## 13.4 Namespaced OAuth scopes <a href="#134-namespaced-oauth-scopes" id="134-namespaced-oauth-scopes"></a> + +**[M]** Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation and namespaced so one BB's scopes cannot collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. New GovStack scopes **SHOULD** use `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). + +## 13.5 Authorization is the credential channel <a href="#135-authorization-is-the-credential-channel" id="135-authorization-is-the-credential-channel"></a> + +**[M+R]** Credentials **MUST NOT** be declared in URL paths, query parameters, cookies, or fragments. The `Authorization` header is the only declared credential channel for token-based schemes. + +## 13.6 API keys only for operational endpoints <a href="#136-api-keys-only-for-operational-endpoints" id="136-api-keys-only-for-operational-endpoints"></a> + +**[R]** API keys **MAY** be declared on operational endpoints (`/health` and similar per [§5.9](../part-b/5-url-structure-and-versioning.md#59-unversioned-health-endpoint)). They **MUST NOT** be declared on operations that read or write personal data. + +## Note on consent propagation <a href="#note-on-consent-propagation" id="note-on-consent-propagation"></a> + +Cross-service propagation of end-user consent or authorisation context (for example, when one BB calls another on behalf of a data subject) is not specified by this guide. The expected mechanism is OAuth-native (scopes, claims, or RFC 9396 Rich Authorization Requests inside the access token), and the concrete shape belongs in the relevant consent, authorisation, and inter-BB trust specifications, not here. + +## 13.7 Protected transport <a href="#137-protected-transport" id="137-protected-transport"></a> + +**[M+R]** Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. Negotiated TLS **MUST** be version 1.3 or higher, inherited from `govstack-cfr-security#req-1` and not relaxable by a BB specification. Remaining TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration are outside this guide. diff --git a/api-design-guide/part-d/14-idempotency.md b/api-design-guide/part-d/14-idempotency.md new file mode 100644 index 0000000..a89cf2b --- /dev/null +++ b/api-design-guide/part-d/14-idempotency.md @@ -0,0 +1,37 @@ +--- +description: "Rules for the Idempotency-Key header contract that lets clients safely retry non-idempotent POST requests." +--- + +# 14. Idempotency + +{% hint style="info" %} +**Intent.** Safe retries. A shared idempotency contract is what lets a client retry a payment, registration, or message submission without risk of duplicates. + +**Applies to:** Universal. On the AsyncAPI surface the same idempotency concept applies using the message metadata rules in [§17.8](../part-d/17-asyncapi-channel-rules.md#178-message-headers-and-idempotency-metadata). + +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§14.1](#141-idempotency-key-on-non-idempotent-posts) and [§14.3](#143-documented-replay-window) constrain the specification: declare the header and document the replay-window contract. [§14.2](#142-opaque-client-generated-keys) and [§14.4](#144-replay-returns-original-response)–[§14.6](#146-naturally-idempotent-designs) are behavioural-contract rules: they bind a conforming implementation at run time and require implementation-level tests rather than spec linting. Concrete replay-window values, key-store retention, and the maximum accepted key length are deployment values for implementation profiles; replay enforcement and key storage are operational concerns outside this guide ([§1.2](../1-introduction.md#12-scope)). +{% endhint %} + +## 14.1 Idempotency-Key on non-idempotent POSTs <a href="#141-idempotency-key-on-non-idempotent-posts" id="141-idempotency-key-on-non-idempotent-posts"></a> + +**[M+R]** Except where [§14.6](#146-naturally-idempotent-designs) applies, POST endpoints that create resources, move value, submit irreversible requests, send messages, create subscriptions, start long-running jobs, or trigger other non-idempotent processing **MUST** require and accept an `Idempotency-Key` header. Other mutating POST actions **SHOULD** support it unless their naturally idempotent contract documents duplicate handling. Read-like POSTs such as search **MAY** support it. GovStack pins the header syntax and error semantics from `draft-ietf-httpapi-idempotency-key-header-07`; this guide is the stable GovStack profile if that work-in-progress draft changes or expires. + +## 14.2 Opaque client-generated keys <a href="#142-opaque-client-generated-keys" id="142-opaque-client-generated-keys"></a> + +**[R]** The key **MUST** be an opaque, client-generated, high-entropy value and **SHOULD** be a UUID. On the wire it **MUST** use the Structured Field String syntax pinned from draft revision 07, including the required quotation marks. The specification **MUST** document the accepted syntax and maximum length, and the server **MUST** reject a malformed, missing-required, or oversized key with `400 Bad Request` before processing the operation. + +## 14.3 Documented replay window <a href="#143-documented-replay-window" id="143-documented-replay-window"></a> + +**[R]** The spec **MUST** document the idempotency replay-window contract, the required minimum replay window or controlling configuration parameter, and what happens after expiry. Within the documented window the key **MUST** retain the semantics in [§14.4](#144-replay-returns-original-response) and [§14.5](#145-key-reuse-and-fingerprint-mismatch); after expiry the server **MAY** process the same key as a new request only if that behaviour is stated explicitly. Concrete retention values belong in implementation profiles. + +## 14.4 Replay returns original response <a href="#144-replay-returns-original-response" id="144-replay-returns-original-response"></a> + +**[R]** A completed repeated request with the same lookup scope, key, and fingerprint within the documented window **MUST** return the original operation result: the same status, body, and result-defining representation headers such as `Content-Type` and `Location`. Per-attempt, temporal, security, tracing, rate-limit, retry, and hop-by-hop headers **MUST** be regenerated or omitted rather than replayed; this includes `Date`, `traceparent`, `tracestate`, `RateLimit`, `Retry-After`, and `Set-Cookie`. + +## 14.5 Key reuse and fingerprint mismatch <a href="#145-key-reuse-and-fingerprint-mismatch" id="145-key-reuse-and-fingerprint-mismatch"></a> + +**[R]** The server's idempotency lookup scope **MUST** include the effective HTTP method, canonical target URI, key, and, for authenticated operations, the authenticated client and tenant or equivalent authorization partition. The request fingerprint **MUST** include the method, canonical target URI, request content type, canonicalised body, and every documented header that changes operation semantics. Reusing a key in the same lookup scope with a different fingerprint **MUST** return `422 Unprocessable Content`; a matching retry received while the original remains in flight **MUST** return `409 Conflict`. These are GovStack **MUST** requirements even though draft revision 07 expresses the status-code choices as **SHOULD**. + +## 14.6 Naturally idempotent designs <a href="#146-naturally-idempotent-designs" id="146-naturally-idempotent-designs"></a> + +**[R]** Naturally idempotent designs **MAY** satisfy this section without an `Idempotency-Key` header when idempotency is already guaranteed by the resource contract, for example `PUT /v1/resources/{clientProvidedId}` or creation with a documented unique business key that returns the existing resource or a stable conflict on duplicate submission. The spec **MUST** document that duplicate-handling behaviour per operation. Because [§10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers) makes BB-generated identifiers server-assigned by default, `PUT`-with-client-id creation is available only where the resource is keyed by a client-supplied or statutory identifier ([§10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)). diff --git a/api-design-guide/part-d/15-asynchronous-operations.md b/api-design-guide/part-d/15-asynchronous-operations.md new file mode 100644 index 0000000..ccc5b39 --- /dev/null +++ b/api-design-guide/part-d/15-asynchronous-operations.md @@ -0,0 +1,51 @@ +--- +description: "The local Operation resource shape and polling pattern BBs use for operations that cannot complete synchronously." +--- + +# 15. Asynchronous operations + +{% hint style="info" %} +**Intent.** A single async pattern across BBs. Without one, every BB picks a different status code (200, 201, or 202) and a different polling shape, and integrators write per-BB glue. In this guide, *Operation resource* (capitalised) is the polling resource defined in this section; lowercase *operation* means an OpenAPI or AsyncAPI operation. + +**Applies to:** OpenAPI surface (HTTP/REST). The Operation resource is defined locally by each BB; the `202` and polling mechanics are HTTP-specific. +{% endhint %} + +## 15.1 202 with Operation Location <a href="#151-202-with-operation-location" id="151-202-with-operation-location"></a> + +**[M+R]** Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource and **MUST** return the current Operation representation in the response body. + +## 15.2 Local Operation resource shape <a href="#152-local-operation-resource-shape" id="152-local-operation-resource-shape"></a> + +**[M+R]** A BB that exposes long-running work **MUST** define its Operation schema locally. Its identifier **MUST** be an opaque string and clients **MUST NOT** infer a UUID or any other internal format. The local schema **MUST** document how the identifier, lifecycle state, result, error, and any progress metadata are represented, including when each state-dependent field is present. This guide does not fix those field names or shapes. A shared Operation schema is deferred until multiple BBs demonstrate a stable reusable contract. + +## 15.3 Documented Operation lifecycle <a href="#153-documented-operation-lifecycle" id="153-documented-operation-lifecycle"></a> + +**[M+R]** The local Operation contract **MUST** distinguish terminal from non-terminal states and **MUST** document the result, error, polling, and cancellation semantics for each applicable state. This guide does not prescribe a status enum or lifecycle model. + +**Example (informative).** An in-progress Operation resource: + +```json +{ + "id": "op_7JpQ9m2W4xK8fR3cT6vN1", + "status": "RUNNING", + "createdAt": "2026-07-10T08:30:00Z", + "updatedAt": "2026-07-10T08:30:05Z", + "progress": 40 +} +``` + +## 15.4 Polling the Operation resource <a href="#154-polling-the-operation-resource" id="154-polling-the-operation-resource"></a> + +**[M+R]** A BB exposing an Operation resource **MUST** make it pollable with `GET` at the URI returned in `Location`. The conventional path **SHOULD** be `/v{major}/operations/{operationId}`. A non-terminal polling response **SHOULD** include `Retry-After` when the server can advise a useful minimum polling interval. + +## 15.5 Cancellation via cancel sub-resource <a href="#155-cancellation-via-cancel-sub-resource" id="155-cancellation-via-cancel-sub-resource"></a> + +**[M+R]** Cancellation, when supported, **MUST** be documented and discoverable from the Operation contract. The conventional action **SHOULD** be `POST /v{major}/operations/{operationId}/cancel`. + +## 15.6 Webhook completion notification <a href="#156-webhook-completion-notification" id="156-webhook-completion-notification"></a> + +**[R]** Long-running operations **SHOULD** support completion notification via webhook ([§16](../part-d/16-cloudevents-and-webhooks.md)) rather than requiring clients to poll indefinitely. The threshold at which notification becomes expected is operational and per-BB. + +## 15.7 Documented result retention <a href="#157-documented-result-retention" id="157-documented-result-retention"></a> + +**[R]** Operation result availability **MUST** be documented as a consumer-visible contract. A reference specification **SHOULD** state the required minimum retention or the configuration parameter that controls it. Concrete retention values belong in implementation profiles. diff --git a/api-design-guide/part-d/16-cloudevents-and-webhooks.md b/api-design-guide/part-d/16-cloudevents-and-webhooks.md new file mode 100644 index 0000000..2e2ed50 --- /dev/null +++ b/api-design-guide/part-d/16-cloudevents-and-webhooks.md @@ -0,0 +1,75 @@ +--- +description: "Rules governing the CloudEvents envelope, event-type and source naming, optional signing, and subscription management for webhooks, brokered channels, and event streams." +--- + +# 16. CloudEvents and webhooks + +{% hint style="info" %} +**Intent.** A small event contract across BBs. The shared baseline is the CloudEvents envelope and the type/source convention. Transport security, optional message signing, and delivery-failure behaviour are documented only where the selected surface needs them. + +**Applies to:** Event-driven. CloudEvents rules apply to HTTP webhooks, brokered event channels, and event streams. OpenAPI and AsyncAPI are documentation formats for those surfaces, not alternative event-envelope standards. + +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§16.1](#161-event-surfaces-documented)–[§16.4](#164-stable-cloudevents-source), [§16.10](#1610-documented-delivery-failure-contract), and [§16.11](#1611-subscription-management-interfaces) constrain the specification. [§16.5](#165-optional-signed-event-delivery)–[§16.9](#169-readiness-for-a-shared-signature-profile) apply only when a BB opts into event signing. +{% endhint %} + +## 16.1 Event surfaces documented <a href="#161-event-surfaces-documented" id="161-event-surfaces-documented"></a> + +**[M+R]** Event-driven APIs **MUST** be documented. HTTP push **MUST** use OpenAPI 3.1 `webhooks`; brokered transports and event streams (MQTT, AMQP, Kafka, WebSockets, SSE) **MUST** use AsyncAPI 3.0. + +## 16.2 CloudEvents envelope required <a href="#162-cloudevents-envelope-required" id="162-cloudevents-envelope-required"></a> + +**[M]** GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `"1.0"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. On the HTTP webhooks surface the event **MUST** be delivered in CloudEvents structured content mode with media type `application/cloudevents+json`; binary content mode **MUST NOT** be used. [§17.6](../part-d/17-asyncapi-channel-rules.md#176-structured-cloudevents-json-payloads) imposes the same requirement on the AsyncAPI surface. + +## 16.3 Reverse-DNS event types <a href="#163-reverse-dns-event-types" id="163-reverse-dns-event-types"></a> + +**[M]** Event `type` names **MUST** be stable, globally collision-resistant, and include the BB's registered code from [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). New GovStack event types **SHOULD** use the reverse-DNS shape `global.govstack.{bb-code}.{resource}.{action}`. The event type identifies the semantic event kind and **MUST NOT** include the major API version; transport-contract versioning is carried separately ([§18.2](../part-d/18-compatibility-and-lifecycle.md#182-major-version-in-path-or-channel)). + +## 16.4 Stable CloudEvents source <a href="#164-stable-cloudevents-source" id="164-stable-cloudevents-source"></a> + +**[M+R]** The CloudEvents `source` field **MUST** be a stable, non-empty URI-reference identifying the publishing BB or BB surface; an absolute URI or URN **SHOULD** be used. It **MUST NOT** identify a specific deployment host, pod, broker, queue, or environment. + +**Example (informative).** A structured CloudEvents JSON event with a GovStack trace extension attribute: + +```json +{ + "specversion": "1.0", + "id": "5e0c63c2-2b8a-4d3f-9a51-7c6b0d9e8f21", + "source": "urn:govstack:bb:registration", + "type": "global.govstack.registration.application.approved", + "time": "2026-07-10T08:30:00Z", + "datacontenttype": "application/json", + "traceparent": "00-6f1c3f0e2a9b4c8d7e6f5a4b3c2d1e0f-5b1e4d7ca8f01e2d-01", + "data": { + "applicationId": "3f6c0e63-9f7e-4d51-a3ce-58b2c7d0f3a1", + "approvedAt": "2026-07-10T08:29:58Z" + } +} +``` + +## 16.5 Optional signed event delivery <a href="#165-optional-signed-event-delivery" id="165-optional-signed-event-delivery"></a> + +**[R]** Event delivery **MAY** use message-level signing when the BB's threat model requires authenticity or integrity beyond authenticated transport. Signing is not part of the baseline GovStack event contract. Requirements for an explicitly adopted profile are in [§16.6](#166-signature-metadata-when-used)–[§16.8](#168-separate-experimental-signing-profile). + +## 16.6 Signature metadata when used <a href="#166-signature-metadata-when-used" id="166-signature-metadata-when-used"></a> + +**[R]** When the optional profile uses GovStack-owned metadata, an OpenAPI webhook signature **MUST** travel in `GovStack-Signature` and an AsyncAPI transport/application metadata field **MUST** be named `govstackSignature`. When a protocol binding defines a standard signature field, that field **SHOULD** be used and its mapping **MUST** be documented. A surface that does not adopt signing **MUST NOT** require signature metadata. + +## 16.7 Replay-detectable signed material <a href="#167-replay-detectable-signed-material" id="167-replay-detectable-signed-material"></a> + +**[R]** When signing is adopted, the signed material **MUST** include the event body, the event `id`, and either the CloudEvents `time` value or a signature timestamp, so receivers have the inputs needed to detect replays. + +## 16.8 Separate experimental signing profile <a href="#168-separate-experimental-signing-profile" id="168-separate-experimental-signing-profile"></a> + +**[R]** Event signing belongs in the separate optional `experimental/govstack-openapi-signing-profile.yaml` artifact, not in either baseline common schema artifact. A BB **MAY** adopt this profile explicitly, but it **MUST** pin the profile version and **MUST** document its key discovery, key rotation, replay policy, protocol mapping, and conformance tests. The profile remains incomplete pending a shared key-discovery and replay-policy contract and **MUST NOT** be presented as an ecosystem-wide baseline. + +## 16.9 Readiness for a shared signature profile <a href="#169-readiness-for-a-shared-signature-profile" id="169-readiness-for-a-shared-signature-profile"></a> + +A shared signature profile is outside the baseline. It should be considered only after key discovery, key rotation, replay-window enforcement, protocol mappings, conformance test vectors, and interoperable implementations in at least two commonly used GovStack implementation languages exist. Operational signing controls remain outside this guide ([§1.2](../1-introduction.md#12-scope)). + +## 16.10 Documented delivery-failure contract <a href="#1610-documented-delivery-failure-contract" id="1610-documented-delivery-failure-contract"></a> + +**[R]** HTTP webhook delivery-failure behaviour **MUST** be documented per subscription. A reference specification **MUST** define the portable contract fields: whether redelivery is attempted, whether failed deliveries are stored, and how a subscriber can identify or recover failed deliveries. Concrete retry counts, backoff intervals, and failure-store retention values belong in implementation profiles. Failed deliveries **SHOULD** end in a dead-letter queue or equivalent failure store accessible to the subscription owner. + +## 16.11 Subscription management interfaces <a href="#1611-subscription-management-interfaces" id="1611-subscription-management-interfaces"></a> + +**[M+R]** Subscription management **MUST** expose documented interfaces to create, list, and delete a subscription. If the subscription adopts message signing, it **MUST** also expose or document how to rotate or redistribute the verification material used by the selected profile. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane. diff --git a/api-design-guide/part-d/17-asyncapi-channel-rules.md b/api-design-guide/part-d/17-asyncapi-channel-rules.md new file mode 100644 index 0000000..c860ace --- /dev/null +++ b/api-design-guide/part-d/17-asyncapi-channel-rules.md @@ -0,0 +1,112 @@ +--- +description: "Rules governing AsyncAPI channel addressing, payload structure, message headers, protocol bindings, and examples for brokered and event-stream surfaces." +--- + +# 17. AsyncAPI channel documentation rules + +{% hint style="info" %} +**Intent.** AsyncAPI documents brokered CloudEvents channels and event streams other than HTTP push webhooks. It does not replace CloudEvents or turn broker operations into a universal abstraction. It makes the portable contract complete enough that an integrator can see what a BB publishes or consumes, on which channels, under which security model, and which transport behaviours are safe to rely on. + +**Applies to:** AsyncAPI surface. If a BB exposes no brokered or event-stream surface (only synchronous REST and/or HTTP push webhooks), [§3](../part-a/3-asyncapi-document-standards.md) and [§17](../part-d/17-asyncapi-channel-rules.md) do not apply. +{% endhint %} + +## 17.1 Send and receive perspective <a href="#171-send-and-receive-perspective" id="171-send-and-receive-perspective"></a> + +**[M+R]** Each AsyncAPI document **MUST** define the BB's perspective. An operation with `action: send` means the BB publishes that message to the channel. An operation with `action: receive` means the BB consumes that message from the channel. + +## 17.2 Stable logical channel IDs and native addresses <a href="#172-stable-logical-channel-ids-and-native-addresses" id="172-stable-logical-channel-ids-and-native-addresses"></a> + +**[M+R]** Each entry under AsyncAPI `channels` **MUST** use a stable logical ID and document its mapping to the protocol-native address. New GovStack channel IDs **SHOULD** use `global.govstack.{bb-code}.v{major}.{resource}.{event}`, with the registered code from [§9.11](../part-c/9-json-conventions-and-naming.md#911-single-registered-bb-code). The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics. + +## 17.3 No personal data in channels <a href="#173-no-personal-data-in-channels" id="173-no-personal-data-in-channels"></a> + +**[R]** Logical channel IDs, native addresses, topic names, queue names, routing keys, and channel parameters **MUST NOT** contain personal data, secrets, access tokens, phone numbers, email addresses, national identifiers, names, dates of birth, exact addresses, or other directly identifying attributes. Use opaque IDs or claim-protected payload fields instead. + +## 17.4 Declared channel parameters <a href="#174-declared-channel-parameters" id="174-declared-channel-parameters"></a> + +**[M+R]** Channel parameters **MAY** be used for non-personal routing values such as tenant, ministry, service, region, resource type, or shard. Each parameter **MUST** be declared under the AsyncAPI channel `parameters` object with a non-empty `description` stating its routing semantics. The AsyncAPI 3 Parameter Object carries no `schema` field: a parameter whose permitted values form a closed set **MUST** declare them with `enum`, and one whose values are open **SHOULD** carry `examples`. + +## 17.5 No environment names in addresses <a href="#175-no-environment-names-in-addresses" id="175-no-environment-names-in-addresses"></a> + +**[M+R]** Environment names (`dev`, `test`, `prod`), broker implementation names, and deployment-specific prefixes **SHOULD NOT** appear in channel addresses. They belong in `servers`, server variables, broker configuration, or deployment routing unless a protocol profile explicitly requires them. + +## 17.6 Structured CloudEvents JSON payloads <a href="#176-structured-cloudevents-json-payloads" id="176-structured-cloudevents-json-payloads"></a> + +**[M]** AsyncAPI Message Objects for GovStack domain events **MUST** use `contentType: application/cloudevents+json` and structured CloudEvents JSON: the message payload is the complete CloudEvent, and any GovStack-owned domain data **MUST** live under the CloudEvents `data` field. The base envelope does not require `data` or constrain its JSON shape; each local Message Object makes that decision for its event. This provides one portable, schema-validatable event shape across brokered transports. + +## 17.7 Shared CloudEvents envelope schema <a href="#177-shared-cloudevents-envelope-schema" id="177-shared-cloudevents-envelope-schema"></a> + +**[M]** Each BB **MUST** define its Message Objects locally. A domain-event message payload **MUST** compose `#/components/schemas/CloudEventEnvelope` from the pinned `govstack-asyncapi-common.yaml` with a local schema that specialises the event `type` and, when present, `data`. A rejection message **MUST** either reference the shared `GovStackAsyncError` schema directly or use it as CloudEvent `data`. Operation message references **MUST** point to the relevant message entries under the operation's referenced channel, per AsyncAPI 3.0. Security schemes, headers, examples, correlation, and protocol bindings remain local because they require BB- or transport-specific values. + +**Example (informative).** A local Message Object using the shared envelope: + +```yaml +components: + messages: + recordCreated: + contentType: application/cloudevents+json + payload: + allOf: + - $ref: './common/govstack-asyncapi-common.yaml#/components/schemas/CloudEventEnvelope' + - type: object + description: Registry-specific event type and domain payload. + required: [data] + properties: + type: + description: Stable semantic event type. + const: global.govstack.registry.record.created + data: + $ref: '#/components/schemas/RecordCreatedData' +``` + +## 17.8 Message headers and idempotency metadata <a href="#178-message-headers-and-idempotency-metadata" id="178-message-headers-and-idempotency-metadata"></a> + +**[M+R]** GovStack-owned transport/application message headers **SHOULD** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. If optional signing is adopted, signature metadata **MUST** follow [§16.6](../part-d/16-cloudevents-and-webhooks.md#166-signature-metadata-when-used). Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **SHOULD** use `idempotencyKey`. + +## 17.9 Message localisation headers <a href="#179-message-localisation-headers" id="179-message-localisation-headers"></a> + +**[M+R]** Message headers used for localisation **MUST** be `acceptLanguage` on inbound command/request messages and `contentLanguage` on outbound localised messages. Stable fields such as identifiers, enum values, timestamps, currency codes, and error codes **MUST NOT** be translated. + +## 17.10 Security schemes cover every operation <a href="#1710-security-schemes-cover-every-operation" id="1710-security-schemes-cover-every-operation"></a> + +**[M+R]** AsyncAPI security schemes **MUST** be declared under `components.securitySchemes` and applied on `servers`, `operations`, or both so every operation is covered. Optional message signing ([§16.5](../part-d/16-cloudevents-and-webhooks.md#165-optional-signed-event-delivery)) is message-level integrity and **MUST NOT** be treated as a substitute for broker, server, or operation authentication. + +## 17.11 Duplicate delivery contract <a href="#1711-duplicate-delivery-contract" id="1711-duplicate-delivery-contract"></a> + +**[R]** When the selected protocol or deployment can redeliver a message and consumers need to handle duplicates, the operation **MUST** document the duplicate-handling contract. For CloudEvents, the default duplicate identity is the pair `source` plus `id`. Protocol QoS, acknowledgement, and redelivery fields **MUST** use the applicable AsyncAPI binding when available. Specifications **MUST NOT** claim `effectivelyOnce` as a portable transport guarantee; they **MAY** document application-level idempotency or de-duplication instead. + +## 17.12 Ordering only when promised <a href="#1712-ordering-only-when-promised" id="1712-ordering-only-when-promised"></a> + +**[R]** Ordering **MUST** be documented only when consumers are allowed to rely on it. When ordering is promised, the applicable protocol binding or operation description **MUST** identify its scope and key, such as a Kafka partition key or an ordered queue. A specification with no ordering promise does not need a placeholder declaration. + +## 17.13 Public delivery-management capabilities <a href="#1713-public-delivery-management-capabilities" id="1713-public-delivery-management-capabilities"></a> + +**[R]** Redelivery, dead-letter handling, retention, and replay **MUST** be documented when they are part of the public contract available to a consumer. The specification **MUST** use the applicable protocol binding, channel configuration, or a linked protocol profile where one exists. Capabilities that are deployment-internal or unavailable to consumers **MAY** be omitted. + +## 17.14 Implementation values in protocol profiles <a href="#1714-implementation-values-in-protocol-profiles" id="1714-implementation-values-in-protocol-profiles"></a> + +**[R]** Concrete retry counts, backoff intervals, retention periods, replay windows, and dead-letter store settings **SHOULD** live in protocol or implementation profiles unless a value is a stable promise to every conforming consumer. The core cross-BB specification **MUST NOT** imply that a broker-specific setting is portable across protocols. + +## 17.15 No universal delivery extensions <a href="#1715-no-universal-delivery-extensions" id="1715-no-universal-delivery-extensions"></a> + +**[R]** `govstack-asyncapi-common.yaml` does not define universal delivery, ordering, redelivery, dead-letter, retention, or replay extensions. A specification **MUST** use standard AsyncAPI bindings first and **MUST** state any remaining consumer-visible promise in `description` or a linked protocol profile. The presence of a custom extension alone **MUST NOT** be treated as an interoperable delivery contract. + +## 17.16 Async rejection error messages <a href="#1716-async-rejection-error-messages" id="1716-async-rejection-error-messages"></a> + +**[M+R]** Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using `GovStackAsyncError` from [§11.6](../part-c/11-errors.md#116-transport-neutral-asynchronous-errors), not an artificial RFC 9457 HTTP `status`. The error message **MUST** be correlated to the original message using [§17.8](#178-message-headers-and-idempotency-metadata) or an equivalent protocol binding. + +## 17.17 Declared request-reply correlation <a href="#1717-declared-request-reply-correlation" id="1717-declared-request-reply-correlation"></a> + +**[M+R]** Request-reply over messaging **MAY** be used where the protocol and use case support it. When used, the AsyncAPI operation **MUST** declare the reply channel or reply address pattern and the correlation mechanism. Fire-and-forget event publication **MUST NOT** pretend to be request-reply. + +## 17.18 Correlated completion signals <a href="#1718-correlated-completion-signals" id="1718-correlated-completion-signals"></a> + +**[M+R]** Long-running asynchronous work triggered by a message **MUST** expose completion through either an operation-status message based on the [§15](../part-d/15-asynchronous-operations.md) Operation resource or an operation-completed domain event. The spec **MUST** document how clients correlate the completion signal to the initiating message. + +## 17.19 Protocol bindings where relevant <a href="#1719-protocol-bindings-where-relevant" id="1719-protocol-bindings-where-relevant"></a> + +**[M+R]** Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide. + +## 17.20 Representative message examples <a href="#1720-representative-message-examples" id="1720-representative-message-examples"></a> + +**[M+R]** AsyncAPI documents **SHOULD** define representative message examples, including headers plus payload where the interaction is not obvious from the schema. Examples for command, event, error, and operation-completion families are especially useful, but filler examples are not required. diff --git a/api-design-guide/part-d/18-compatibility-and-lifecycle.md b/api-design-guide/part-d/18-compatibility-and-lifecycle.md new file mode 100644 index 0000000..b033bf4 --- /dev/null +++ b/api-design-guide/part-d/18-compatibility-and-lifecycle.md @@ -0,0 +1,43 @@ +--- +description: "Rules governing SemVer versioning, backward-compatible and breaking changes, deprecation signalling, and lifecycle transitions across OpenAPI and AsyncAPI artifacts." +--- + +# 18. Compatibility and lifecycle + +{% hint style="info" %} +**Intent.** Specs change without breaking implementers. + +**Applies to:** Universal. SemVer and breaking-change classification apply to both artifacts; URL path versioning in [18.2](#182-major-version-in-path-or-channel) is OpenAPI-specific, channel/version metadata in [18.2](#182-major-version-in-path-or-channel) is AsyncAPI-specific, and the `Deprecation` and `Sunset` *headers* in [18.5](#185-deprecation-and-sunset-headers) are HTTP-specific. AsyncAPI deprecation is declared through AsyncAPI metadata and message/channel/operation descriptions until a stronger ecosystem convention is selected. +{% endhint %} + +## 18.1 SemVer versioning <a href="#181-semver-versioning" id="181-semver-versioning"></a> + +**[M]** `info.version` **MUST** follow SemVer and identify the version of that surface's published contract, not the implementation version. When a surface also exposes a major version in a path, channel, or protocol field, the two **MUST** agree. An implementation may therefore be at `0.16.3` while the contract it serves is at `1.4.0`. + +## 18.2 Major version in path or channel <a href="#182-major-version-in-path-or-channel" id="182-major-version-in-path-or-channel"></a> + +**[M]** A major version increment **MUST** be visible in the canonical contract through the surface's declared versioning mechanism. New GovStack OpenAPI surfaces **SHOULD** carry it in each versioned path key (`/v2/`) rather than duplicating it in `servers`. New AsyncAPI surfaces **SHOULD** carry it in the logical channel ID defined by [§17.2](../part-d/17-asyncapi-channel-rules.md#172-stable-logical-channel-ids-and-native-addresses). Protocol-native addresses **MUST NOT** be rewritten solely to carry a guide-specific version shape. + +## 18.3 Backward-compatible minor changes <a href="#183-backward-compatible-minor-changes" id="183-backward-compatible-minor-changes"></a> + +**[M+R]** Compatibility **MUST** be evaluated as an existing client communicating with a newer server, separately for input and output. A patch increment **MUST** be limited to a backward-compatible correction that does not add public functionality. A minor increment **MAY** add an endpoint; add an optional request field whose absence preserves the old behaviour; broaden values accepted in a request; or add a response field that conforming clients ignore under [§9.8](../part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas). A response enum value **MAY** be added only when the field was declared extensibly under [§9.9](../part-c/9-json-conventions-and-naming.md#99-no-closed-enums-for-growing-sets). For messaging, the same test **MUST** be applied from publisher to existing consumer for sent messages and from existing publisher to consumer for received messages. Additive syntax **MUST NOT** be called compatible when it changes defaults, pagination boundaries, ordering, authorization, delivery guarantees, or other observable semantics. + +## 18.4 Breaking changes bump major version <a href="#184-breaking-changes-bump-major-version" id="184-breaking-changes-bump-major-version"></a> + +**[M+R]** A breaking change **MUST** be released as a new major version. Breaking changes include removing or renaming an endpoint, operation, message, field, or enum value; adding a required request field; rejecting a previously accepted request; widening the type, length, format, or closed-enum values a server may emit beyond the old response schema; ceasing to emit a required response field; changing a success status, media type, default, identifier construction, field presence, pagination or sort behaviour, error-code meaning, idempotency behaviour, security requirement or scope, event meaning, delivery guarantee, or ordering guarantee. Moving a contract component in a way that breaks generated-client references **MUST** also be treated as breaking even when the serialized wire shape is unchanged. + +## 18.5 Deprecation and Sunset headers <a href="#185-deprecation-and-sunset-headers" id="185-deprecation-and-sunset-headers"></a> + +**[M+R]** Deprecated HTTP endpoints **MUST** return a `Deprecation` header per RFC 9745 (a Structured Field date carrying the deprecation timestamp, for example `Deprecation: @1735689600`) and a `Link` with relation `deprecation` pointing to migration documentation. When removal is planned, they **MUST** additionally return a `Sunset` header per RFC 8594; its timestamp **MUST NOT** precede the deprecation timestamp. The GovStack governance process owns the minimum deprecation window and maximum number of concurrent major versions. + +## 18.6 Clients ignore unknown fields <a href="#186-clients-ignore-unknown-fields" id="186-clients-ignore-unknown-fields"></a> + +(informative) Conforming clients are expected to ignore unknown JSON fields and unknown enum values. This is what makes the additive changes in [18.3](#183-backward-compatible-minor-changes) non-breaking; the design constraint that enables it is in [§9.8](../part-c/9-json-conventions-and-naming.md#98-forward-compatible-schemas). + +## 18.7 AsyncAPI deprecation metadata <a href="#187-asyncapi-deprecation-metadata" id="187-asyncapi-deprecation-metadata"></a> + +**[M+R]** AsyncAPI channels, operations, and messages **MUST** declare consumer-visible deprecation and replacement guidance in their `description`. They **MAY** also use the experimental `x-govstack-deprecated` extension with `since`, `sunset`, `replacement`, and `reason` where supported by tooling. + +## Note on retrofitting <a href="#note-on-retrofitting" id="note-on-retrofitting"></a> + +Bringing an existing BB into conformance with the JSON conventions ([§9.2](../part-c/9-json-conventions-and-naming.md#92-camelcase-field-names), [§10.x](../part-c/10-data-types-and-formats.md)) or the opaque-identifier rule ([§10.1](../part-c/10-data-types-and-formats.md#101-opaque-server-generated-identifiers)) changes the wire contract and is therefore a breaking change under [§18.4](#184-breaking-changes-bump-major-version): it **MUST** be released as a new major version. The transition schedule for existing BBs is governance, not design. diff --git a/api-design-guide/part-e/19-localisation.md b/api-design-guide/part-e/19-localisation.md new file mode 100644 index 0000000..11a22a0 --- /dev/null +++ b/api-design-guide/part-e/19-localisation.md @@ -0,0 +1,29 @@ +--- +description: "Rules governing localisation of API content: request-language handling, translation boundaries for stable content, default language, and response-language declaration." +--- + +# 19. Localisation + +{% hint style="info" %} +**Intent.** A single language model for a multi-country ecosystem. + +**Applies to:** Universal. On the OpenAPI surface localisation uses `Accept-Language` and `Content-Language` HTTP headers. On the AsyncAPI surface it uses the `acceptLanguage` and `contentLanguage` message headers defined in [§17](../part-d/17-asyncapi-channel-rules.md). + +**Layer ([§1.8](../1-introduction.md#18-layering-what-this-guide-constrains)).** [§19.3](#193-declared-default-language)–[§19.4](#194-declare-the-response-language) constrain the specification: declare the default and selected response languages. [§19.1](#191-honour-the-request-language)–[§19.2](#192-never-translate-stable-content) are behavioural-contract rules verified by implementation-level conformance tests. The set of languages a given BB must support is per-BB and per-deployment policy, not fixed here. +{% endhint %} + +## 19.1 Honour the request language <a href="#191-honour-the-request-language" id="191-honour-the-request-language"></a> + +**[R]** Localisable content (error `title`/`detail`, enum display labels, free-text status messages) **MUST** respect the request language header appropriate to the surface: `Accept-Language` for HTTP, `acceptLanguage` for AsyncAPI messages. + +## 19.2 Never translate stable content <a href="#192-never-translate-stable-content" id="192-never-translate-stable-content"></a> + +**[R]** Stable content (HTTP Problem `type`, transport-neutral asynchronous error `code`, enum values, identifiers, timestamps, currency codes) **MUST NOT** be translated. + +## 19.3 Declared default language <a href="#193-declared-default-language" id="193-declared-default-language"></a> + +**[R]** The specification **MUST** declare its default response language. English **SHOULD** be used as the cross-border fallback when deployment policy or law does not select another default. + +## 19.4 Declare the response language <a href="#194-declare-the-response-language" id="194-declare-the-response-language"></a> + +**[M+R]** Responses or messages with localised content **MUST** include the response language header appropriate to the surface: `Content-Language` for HTTP, `contentLanguage` for AsyncAPI messages. diff --git a/api-design-guide/part-e/20-conformance-and-validation.md b/api-design-guide/part-e/20-conformance-and-validation.md new file mode 100644 index 0000000..1b693b9 --- /dev/null +++ b/api-design-guide/part-e/20-conformance-and-validation.md @@ -0,0 +1,44 @@ +--- +description: "Rules governing mechanical conformance verification of BB API specs: schema validation, the GovStack Spectral ruleset, and declared guide-version conformance." +--- + +# 20. Conformance and validation + +{% hint style="info" %} +**Intent.** The contract is mechanically verified. + +**Applies to:** Universal. AsyncAPI artifacts have parallel validators (`asyncapi/parser` or AsyncAPI CLI) and Spectral support. +{% endhint %} + +## 20.1 Every file passes validation <a href="#201-every-file-passes-validation" id="201-every-file-passes-validation"></a> + +**[M]** Every canonical OpenAPI entrypoint **MUST** pass `openapi-spec-validator` for its declared qualified 3.1 patch, with local references resolved. Every canonical AsyncAPI entrypoint **MUST** pass an AsyncAPI 3.0 parser/validator such as `@asyncapi/parser`. A referenced schema fragment **MUST** validate against its own declared schema dialect and **MUST NOT** be rejected merely because it is not a standalone OpenAPI or AsyncAPI document. + +## 20.2 Passes the GovStack Spectral ruleset <a href="#202-passes-the-govstack-spectral-ruleset" id="202-passes-the-govstack-spectral-ruleset"></a> + +**[M]** Every BB API spec **MUST** pass the exact GovStack Spectral ruleset version declared under [§20.3](#203-declared-guide-conformance-version) for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of `[M+R]` rules ([§1.9](../1-introduction.md#19-rule-enforcement-classes)). Ruleset `0.1.0-draft` **MUST** cover the OpenAPI, CloudEvents, and AsyncAPI documentation rules recorded in its coverage manifest. Validation **MUST** fail when the declared ruleset artifact is unavailable or differs from the exact declared version; tooling **MUST NOT** select “latest” or fall back by major/minor compatibility. + +## 20.3 Declared guide conformance version <a href="#203-declared-guide-conformance-version" id="203-declared-guide-conformance-version"></a> + +**[M]** Each canonical specification file **MUST** declare the exact guide and ruleset versions it targets in the `info`-level `x-govstack-api-guide` object using `version` and `rulesetVersion`, each an exact SemVer value rather than a range. For this draft both values **MUST** be `0.1.0-draft`. An optional `exceptions` array **MUST** contain objects with exactly these fields: `rule` (guide rule ID), `scope` (RFC 6901 JSON Pointer into this canonical document), `rationale` (non-empty explanation), `record` (absolute HTTPS URI for the approved public record), `reviewedBy` (non-empty approving authority), `reviewedAt` (calendar date `YYYY-MM-DD`), and `expiresAt` (calendar date `YYYY-MM-DD`). An exception **MUST** suppress only its named rule at or below its declared scope. An expired entry, invalid field, or exception not approved under [§1.6](../1-introduction.md#16-exception-process) **MUST** fail validation rather than suppress the rule. Offline validation **MUST NOT** require dereferencing the record URI. + +**Example (informative).** + +```yaml +info: + x-govstack-api-guide: + version: 0.1.0-draft + rulesetVersion: 0.1.0-draft + exceptions: + - rule: "5.2" + scope: /paths/~1v1~1status/get + rationale: Legacy statutory endpoint name cannot change before v2. + record: https://docs.govstack.global/api-exceptions/registration-2026-004 + reviewedBy: GovStack API Working Group + reviewedAt: "2026-07-10" + expiresAt: "2027-01-31" +``` + +## Note on governance <a href="#note-on-governance" id="note-on-governance"></a> + +Publication gates, conformance levels, exception handling, transition timelines, and CI implementation are owned by the GovStack governance process and are outside this guide. diff --git a/api-design-guide/rules.yaml b/api-design-guide/rules.yaml new file mode 100644 index 0000000..dd7c483 --- /dev/null +++ b/api-design-guide/rules.yaml @@ -0,0 +1,1377 @@ +# GENERATED FILE. DO NOT HAND-EDIT. +# Regenerate with: python3 tools/build_rules_index.py +# +# Invariant: for each rule, `page` + `anchor` locate it in the book. +# The same `#anchor` fragment resolves on both GitHub and GitBook. +guide: GovStack Cross-BB API Design Guide +version: 0.1.0-draft +rule_count: 171 +rules: +- id: "2.1" + title: "OpenAPI 3.1 required" + class: M + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 21-openapi-31-required + text: "The spec **MUST** declare an explicit, published OpenAPI 3.1 patch version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.1.0-draft` qualify `openapi: 3.1.0`, `3.1.1`, and `3.1.2`; tooling **MUST** treat those patches as the same OAS 3.1 feature set. OpenAPI 3.0 and earlier **MUST NOT** be used. A later OpenAPI minor version, including 3.2, **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it." +- id: "2.2" + title: "One canonical OpenAPI entrypoint" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 22-one-canonical-openapi-entrypoint + text: "In the absence of `api/index.yaml`, the canonical OpenAPI entrypoint **MUST** be located at `api/openapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned API surfaces **MUST** enumerate every surface in `api/index.yaml` using §4.5. Either discovery form **MUST** identify exactly one canonical artifact per surface. The legacy `api/swagger.yaml` and `api/swagger.json` names still used by some BBs are not canonical under this guide." +- id: "2.3" + title: "No divergent OpenAPI copies" + class: R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 23-no-divergent-openapi-copies + text: "Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent copies. OpenAPI snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it.\nAn operation-free shared component library under `api/common/` is referenced support material, not a canonical API surface or a divergent copy." +- id: "2.4" + title: "Passes openapi-spec-validator" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 24-passes-openapi-spec-validator + text: "The canonical entrypoint **MUST** pass `openapi-spec-validator` for its declared OpenAPI 3.1 patch version, with every local reference resolved. Referenced JSON Schema fragments **MUST** validate against their declared dialect but are not required to be standalone OpenAPI documents." +- id: "2.5" + title: "Complete info block" + class: M + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 25-complete-info-block + text: "The `info` block of each canonical file **MUST** include `title`, `version` (SemVer), and a useful `description`. It **SHOULD** include `contact`; repository governance may supply the maintainer contact when it does not belong in the API contract. Where a BB ships per-surface canonical files (2.2), each surface carries its own `info.version` and versions independently." +- id: "2.6" + title: "Meaningful servers block" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD", "MAY"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 26-meaningful-servers-block + text: "The `servers` block **MUST** be non-empty and **MUST** describe the intended deployment base URL pattern for the API. Every non-local server URL **MUST** use `https`. Reference specifications that are not tied to a live implementation **SHOULD** use parameterised template URLs with documented variables (for example, `https://{gatewayHost}/{bbCode}`). Because §5.1 places `/v{N}` in each OpenAPI path key, a server URL **MUST NOT** repeat that version segment. Server URLs **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production domains. Reserved documentation domains (for example, `example.org`) **MAY** be used only as variable defaults or examples and **MUST** be labelled as non-production." +- id: "2.7" + title: "Complete operation metadata" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 27-complete-operation-metadata + text: "Every operation **MUST** include a stable, non-empty `operationId` and an accurate `description`. An `operationId` **SHOULD** use a readable camelCase verb-noun form. A concise `summary` and at least one useful `tag` **SHOULD** be present when they improve navigation or generated documentation." +- id: "2.8" + title: "Conditional vendored OpenAPI schemas" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-a/2-openapi-document-standards.md + anchor: 28-conditional-vendored-openapi-schemas + text: "A BB **MAY** reuse the schema-only `govstack-openapi-common.yaml` artifact for `Problem`, `ValidationProblem`, `FieldError`, and `PageInfo`. If it does, the file **MUST** be vendored locally at `api/common/govstack-openapi-common.yaml`, its version **MUST** be pinned explicitly, and the BB **MUST** reference the named schemas rather than copy them. A BB that does not reuse the artifact **MUST** define equivalent schemas locally that satisfy §11 and §12.\nSecurity schemes, parameters, headers, Response Objects, examples, and Operation resources **MUST** be defined locally because their values and semantics belong to the BB contract. They are not part of the shared OpenAPI artifact.\nVendoring is required when reuse is chosen because GovStack BBs are deployed in air-gapped or limited-connectivity environments where remote `$ref` resolution is unreliable." +- id: "3.1" + title: "AsyncAPI 3.0.0 required" + class: M + strengths: ["MUST NOT", "MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 31-asyncapi-300-required + text: "An event-driven BB surface other than HTTP push webhooks **MUST** be documented in AsyncAPI 3 and **MUST** declare an explicit, published AsyncAPI 3 version qualified by the pinned GovStack ruleset. Guide and ruleset version `0.1.0-draft` qualify `asyncapi: 3.0.0` and `asyncapi: 3.1.0`; every rule in this guide applies identically to both. AsyncAPI 2.x and earlier **MUST NOT** be used for new GovStack event-driven surfaces. A later AsyncAPI version **MUST NOT** be used until a GovStack guide and ruleset version explicitly qualifies it." +- id: "3.2" + title: "One canonical AsyncAPI entrypoint" + class: M+R + strengths: ["MUST", "MAY"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 32-one-canonical-asyncapi-entrypoint + text: "In the absence of `api/index.yaml`, the canonical AsyncAPI entrypoint **MUST** be located at `api/asyncapi.yaml`, in YAML. It **MAY** `$ref`-compose other repository files provided every reference resolves. A BB with a custom canonical path or multiple independently versioned event-driven surfaces **MUST** enumerate every surface in `api/index.yaml` using §4.5. Either discovery form **MUST** identify exactly one canonical artifact per surface." +- id: "3.3" + title: "No divergent AsyncAPI copies" + class: R + strengths: ["MUST NOT", "MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 33-no-divergent-asyncapi-copies + text: "Other locations (`spec/.gitbook/assets/`, alternative filenames, JSON copies) **MUST NOT** contain divergent AsyncAPI copies. Event snippets in markdown documentation **MUST** load by reference from a canonical file, not duplicate it.\nAn operation-free shared component library under `api/common/` is referenced support material, not a canonical API surface or a divergent copy." +- id: "3.4" + title: "Passes an AsyncAPI validator" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 34-passes-an-asyncapi-validator + text: "The file **MUST** pass an AsyncAPI 3.0 parser/validator (for example, `@asyncapi/parser` or the AsyncAPI CLI)." +- id: "3.5" + title: "Complete AsyncAPI info block" + class: M + strengths: ["MUST", "SHOULD"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 35-complete-asyncapi-info-block + text: "The `info` block of each canonical AsyncAPI file **MUST** include `title`, `version` (SemVer), and a useful `description`. It **SHOULD** include `contact`; repository governance may supply the maintainer contact when it does not belong in the API contract." +- id: "3.6" + title: "Servers channels operations and messages" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD", "MAY"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 36-servers-channels-operations-and-messages + text: "The file **MUST** declare non-empty `servers`, `channels`, `operations`, and `components.messages`. A document with only schemas and no operations is not an API contract. AsyncAPI `servers` **MUST** describe the intended broker or transport endpoint pattern using AsyncAPI 3.0 server fields (`host`, `protocol`, optional `pathname`, variables, security, and protocol bindings). Reference specifications that are not tied to a live broker **SHOULD** use parameterised server hosts and variables (for example, `host: \"{brokerHost}\"` with `protocol: mqtt`, `protocol: amqp`, `protocol: kafka`, or `protocol: wss`). Server definitions **MUST NOT** point to `localhost`, personal developer machines, undocumented placeholders, or fake production brokers. Reserved documentation domains **MAY** be used only as variable defaults or examples, and **MUST** be labelled as non-production." +- id: "3.7" + title: "Complete AsyncAPI operation metadata" + class: M+R + strengths: ["MUST", "SHOULD", "MAY"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 37-complete-asyncapi-operation-metadata + text: "Every AsyncAPI operation **MUST** have a stable operation identifier (the key under `operations`), an `action` (`send` or `receive`), an accurate `description`, a referenced `channel`, and at least one referenced message. A concise `summary` and at least one useful `tag` **SHOULD** be present when they improve navigation or generated documentation. In AsyncAPI 3.0, root-level operation `messages` **MUST** reference message entries defined on the operation's referenced channel. Channel message entries **MAY** in turn reference reusable message definitions under `components.messages`. §17.6–§17.7 define which domain messages use CloudEvents and how asynchronous rejection messages reuse the common error schema." +- id: "3.8" + title: "Pinned vendored AsyncAPI components" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 38-pinned-vendored-asyncapi-components + text: "A BB that documents GovStack domain events **MUST** reference `CloudEventEnvelope` from a pinned version of `govstack-asyncapi-common.yaml`. A BB that documents asynchronous rejections **MUST** reference `GovStackAsyncError`, and **MUST** reuse `AsyncFieldError` when it exposes field-level errors. The common file **MUST** be vendored locally at `api/common/govstack-asyncapi-common.yaml`, and the pinned version **MUST** be explicit. BB specifications own their Message Objects, security schemes, headers, examples, and protocol bindings because those objects require BB- and transport-specific values." +- id: "3.9" + title: "JSON Schema payload conventions" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-a/3-asyncapi-document-standards.md + anchor: 39-json-schema-payload-conventions + text: "AsyncAPI documents **MUST** use JSON Schema compatible with AsyncAPI 3.0 for payload schemas and **MUST** follow the JSON conventions in §9 and §10 for GovStack-owned payload fields." +- id: "4.1" + title: "Useful schema descriptions" + class: M + strengths: ["MUST", "SHOULD"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 41-useful-schema-descriptions + text: "A schema **MUST** have a `description` when its name and structure do not make its semantics clear. Other schemas **SHOULD** have concise descriptions. Filler text added only to satisfy a universal presence check is not useful documentation." +- id: "4.2" + title: "Examples for bodies and enums" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 42-examples-for-bodies-and-enums + text: "Request and response bodies **SHOULD** have representative examples, especially when conditional fields or multi-step behavior are involved. Every `enum` whose values are not self-explanatory **MUST** document what its values mean; an example alone is insufficient." +- id: "4.3" + title: "No placeholder text" + class: M+R + strengths: ["MUST NOT"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 43-no-placeholder-text + text: "The spec **MUST NOT** contain placeholder text: `TBD`, `Lorem ipsum`, `a, b, c`, content from other BBs with the original BB name still present, or test plans with literal placeholder steps." +- id: "4.4" + title: "Accurate operation descriptions" + class: R + strengths: ["MUST"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 44-accurate-operation-descriptions + text: "Operation `description` **MUST** describe what the operation actually does. (Copy-pasted descriptions that document a different endpoint than the one they sit on are a recurring problem in existing BB specifications.)" +- id: "4.5" + title: "API surface inventory" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 45-api-surface-inventory + text: "A BB that uses only `api/openapi.yaml`, only `api/asyncapi.yaml`, or both default canonical paths **MAY** omit `api/index.yaml`. If neither default file exists, the BB **MUST** provide `api/index.yaml`. The index **MUST** contain `version: 1` and exactly one of: a non-empty `apis` list, or `noApi: true` together with a non-empty `reason`.\nAn OpenAPI or AsyncAPI entry **MUST** contain `type` (`openapi` or `asyncapi`) and `path` (a unique, repository-relative YAML path inside `api/`), and every listed path **MUST** resolve to a canonical specification of the declared type. A surface governed directly by a recognised protocol standard **MAY** instead use `type: standard` with a non-empty `name`, an absolute HTTPS `reference` to its normative specification or profile, and an optional `discovery` value naming its standard discovery endpoint. For example, an OpenID Connect provider can point to the OIDC specification and `/.well-known/openid-configuration`; it does not need a synthetic OpenAPI description of standard protocol endpoints.\nThe `standard` inventory form is provisional while CFR issue #7 is under review. Until GovStack approves a registry or profile for recognised standards and their evidence, these entries **MAY** be used during advisory review but **MUST NOT** produce a passing conformance result. An arbitrary HTTPS reference is not proof that a surface implements the named standard.\n`apis` and `noApi` **MUST NOT** coexist. `noApi: true` means that the BB exposes no service interface at all; it **MUST NOT** be used to mean only that no OpenAPI or AsyncAPI file exists. A repository with neither a discoverable contract nor an explicit `noApi` declaration is non-conformant." +- id: "4.6" + title: "Functional-requirement traceability" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-a/4-documentation-requirements.md + anchor: 46-functional-requirement-traceability + text: "Every GovStack requirement in `spec/**/*.md` **MUST** follow the GovStack Requirements Model. Its heading **MUST** use `### #<number> <title> (<level> <mutability> <verification>)`, where level is `REQUIRED`, `RECOMMENDED`, `DRAFT`, or `DEPRECATED`; mutability is `IMMUTABLE`, `EXTENSIBLE`, `REPLACEABLE`, or `INAPPLICABLE`; and verification is `OBSERVABLE` or `AUDITABLE`. The next non-empty line **MUST** contain its canonical `govstack-...#req-<number>` identifier, the two numbers **MUST** match, and body text **MUST** follow any optional `KF:` metadata lines. A child requirement that changes a parent **MUST** identify the parent with `extends` or `replaces` as defined by the GovStack Specification Framework.\nEvery BB that declares at least one API **MUST** provide `api/coverage.yaml` with `version: 1`. Its requirement entries **MUST** match the active REQUIRED and RECOMMENDED requirement IDs exactly: no missing or extra IDs. DRAFT and DEPRECATED requirements, and requirements classified INAPPLICABLE, are not active coverage obligations. A repository that uses `noApi: true` under §4.5 **MUST NOT** contain `api/coverage.yaml`.\nEach coverage entry **MUST** select exactly one disposition and only its compatible companion field: `operation` with a non-empty `operations` list; `message` with a non-empty `messages` list; `external` with an HTTP(S) `reference`; `non-api` with a non-empty `rationale`; or `planned` with an HTTP(S) `issue`. `non-api` means the active requirement is verified outside the service-interface contract; it is not the Requirements Model's formal `INAPPLICABLE` classifier. A disposition-incompatible companion field **MUST NOT** be present. Values in `operations` and `messages` **MUST** be bare operation or message IDs, and those IDs **MUST** be unique across all canonical surfaces declared by the BB. A `planned` disposition records an API gap and **MUST NOT** be interpreted as coverage; the presence of any `planned` entry **MUST** make full conformance fail until the requirement is implemented and its disposition is updated.\nA REQUIRED requirement **MAY** use `non-api` when it remains applicable but its evidence lives outside the service-interface contract, for example in an audit procedure or policy artifact. The rationale **MUST** identify that verification boundary. `non-api` is traceability, not a waiver and not an `INAPPLICABLE` classification." +- id: "5.1" + title: "Major version in the path" + class: M + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 51-major-version-in-the-path + text: "A versioned HTTP surface **MUST** expose its major contract version unambiguously. New GovStack resource APIs **SHOULD** place it in the URL path as `/v{N}/...` (for example, `/v1/policies`). A recognised protocol standard may use its own version-negotiation mechanism. The standard unversioned endpoints of §5.10 do not carry the API major version." +- id: "5.2" + title: "Plural noun resources" + class: M+R + strengths: ["SHOULD"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 52-plural-noun-resources + text: "Resource paths **SHOULD** use plural nouns (`/policies`, not `/policy`)." +- id: "5.3" + title: "Kebab-case path segments" + class: M + strengths: ["SHOULD"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 53-kebab-case-path-segments + text: "Multi-word path segments **SHOULD** use kebab-case (`/event-subscriptions`). A surface governed by an external standard keeps that standard's spelling, including the `.well-known` prefix that RFC 8615 fixes." +- id: "5.4" + title: "Shallow path nesting" + class: M + strengths: ["SHOULD"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 54-shallow-path-nesting + text: "Path hierarchy **SHOULD** be shallow, with at most two levels of nesting after `/v{N}/`. Deep nesting makes paths hard to read and discourages addressable sub-resources." +- id: "5.5" + title: "Identifiers as path parameters" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 55-identifiers-as-path-parameters + text: "Resource identifiers **MUST** be path parameters, not query parameters. (`DELETE /v1/events/{eventId}`, not `DELETE /v1/event?event_id=...`.)" +- id: "5.6" + title: "Query parameter naming" + class: M + strengths: ["SHOULD"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 56-query-parameter-naming + text: "Query parameter names **SHOULD** follow the JSON naming convention defined in §9." +- id: "5.7" + title: "No verbs in CRUD paths" + class: M+R + strengths: ["SHOULD NOT"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 57-no-verbs-in-crud-paths + text: "Verbs **SHOULD NOT** appear in paths for CRUD operations. (`POST /v1/events`, not `POST /v1/event/new`.)" +- id: "5.8" + title: "Actions as sub-resources" + class: R + strengths: ["SHOULD"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 58-actions-as-sub-resources + text: "Non-CRUD actions **SHOULD** be expressed as sub-resources: `POST /v1/events/{eventId}/cancel`, `POST /v1/operations/{operationId}/cancel`." +- id: "5.9" + title: "Unversioned health endpoint" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD", "MAY"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 59-unversioned-health-endpoint + text: "Each BB **MUST** expose an unversioned operational liveness endpoint at `/health`. Health is carried by the HTTP status: `200` when the service is healthy and able to accept work, `503` when it is temporarily unable to. A consumer **MUST** determine health from the status code; response body fields are informational and **MUST NOT** be required for that determination. The `200` response **MUST** use media type `application/json` and **SHOULD** be minimal; the `503` is an error response and carries the problem envelope of §11.1 like any other. The endpoint **MUST** be cheap and bounded, **MUST NOT** carry citizen authentication, and **MUST NOT** expose system-internal detail such as hostnames, versions, stack traces, or dependency topology. It **MUST NOT** probe an external dependency unless that dependency genuinely determines whether the service can accept work. A separate `/ready` endpoint **MAY** be exposed where readiness and liveness semantics differ, under the same rules." +- id: "5.10" + title: "Standard unversioned endpoints" + class: M + strengths: ["MUST NOT", "MAY"] + surface: OpenAPI + page: part-b/5-url-structure-and-versioning.md + anchor: 510-standard-unversioned-endpoints + text: "A closed set of endpoints sits outside the versioned business surface, because their location or spelling is fixed by something other than this guide. That set is: well-known URIs under `/.well-known/`, whose location RFC 8615 roots at that exact prefix; the operational endpoints of §5.9; and a runtime specification-discovery endpoint where a BB serves one, conventionally `/openapi.json` or `/asyncapi.json`. These endpoints are exempt from §5.1, §5.3, and the collection rules of §12, and they satisfy §13.1 with an explicit empty security requirement (`security: []`) where they are unauthenticated. No other endpoint **MAY** claim this exemption, and a BB **MUST NOT** place a business resource under one of these paths in order to escape the rules above: they are read-only and **MUST NOT** declare `POST`, `PUT`, `PATCH`, or `DELETE`." +- id: "6.1" + title: "GET is safe and idempotent" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 61-get-is-safe-and-idempotent + text: "`GET` **MUST** be safe and idempotent. Requests **MUST NOT** carry a body." +- id: "6.2" + title: "POST creates or performs actions" + class: R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 62-post-creates-or-performs-actions + text: "`POST` **MUST** be used to create a server-assigned resource or to perform an action that is not expressed by another HTTP method. A creation completed during the request **MUST** return `201 Created`; work accepted but not completed **MUST** return `202 Accepted` with an Operation resource; a completed non-creation action **MUST** return `200 OK` with a result or `204 No Content` without one. A POST action **MAY** be naturally idempotent or made retry-safe under §14." +- id: "6.3" + title: "PUT replaces the entire resource" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 63-put-replaces-the-entire-resource + text: "`PUT` **MUST** replace the entire resource and **MUST** be idempotent." +- id: "6.4" + title: "PATCH uses a registered patch format" + class: M+R + strengths: ["MUST", "SHOULD", "MAY"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 64-patch-uses-a-registered-patch-format + text: "`PATCH` partially updates a resource. Its request body **MUST** use a registered patch media type and the operation **MUST** document the selected patch semantics. JSON Merge Patch (RFC 7396) with `application/merge-patch+json` **SHOULD** be the default for simple object updates. Under RFC 7396 a member set to `null` means \"remove this member\", so Merge Patch cannot set a nullable field (§9.4) *to* JSON `null`; it can only remove it. Endpoints where setting a field to `null` must be distinguishable from removing it, or which need element-wise array mutation, **MAY** use RFC 6902 JSON Patch via `application/json-patch+json` or another registered format suited to the contract." +- id: "6.5" + title: "DELETE response semantics" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 65-delete-response-semantics + text: "`DELETE` removes a resource. Synchronous hard delete **MUST** return `204 No Content` with no body. Soft delete or async delete (audit retention, undo window) **MAY** return `200` with a body describing the resulting state, or `202` with an Operation per §15." +- id: "6.6" + title: "POST search for complex queries" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 66-post-search-for-complex-queries + text: "Complex queries that cannot fit in a URL query string **MAY** use `POST /v1/{collection}/search` with a request body. The response **MUST** return `200`, not `201`." +- id: "6.7" + title: "Bulk mutation needs explicit selection" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/6-http-methods.md + anchor: 67-bulk-mutation-needs-explicit-selection + text: "A bulk-mutating or bulk-deleting operation (a `PUT`, `PATCH`, or `DELETE` whose target is a collection rather than a single identified resource) **MUST** require at least one explicit selection parameter. An operation that mutates or deletes every record when invoked with no criteria is forbidden. Resources designated append-only (audit logs, event logs, ledgers) **MUST NOT** expose `PUT`, `PATCH`, or `DELETE`. (Mutable audit logs and filter-less bulk update or delete operations that could rewrite or destroy an entire registry have both been observed in existing BB specifications.)" +- id: "7.1" + title: "200 for successful reads" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 71-200-for-successful-reads + text: "A successful read or completed non-creation action that returns a representation **MUST** use `200 OK`." +- id: "7.2" + title: "201 Created with Location" + class: M + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 72-201-created-with-location + text: "A resource creation that completes during the request **MUST** use `201 Created`. The response **MUST** include a `Location` header pointing to the created resource. An operation that has only been accepted for later processing **MUST NOT** return `201`; it **MUST** use `202` under §7.3." +- id: "7.3" + title: "202 Accepted for async operations" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 73-202-accepted-for-async-operations + text: "Work accepted but not completed during the request **MUST** use `202 Accepted`. The response **MUST** include a `Location` header pointing to an Operation resource and **MUST** return that Operation representation using the local schema defined under §15. `202` **MUST NOT** claim that the requested work succeeded." +- id: "7.4" + title: "204 for void responses" + class: R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 74-204-for-void-responses + text: "A successful synchronous DELETE or other successful operation with no response representation **MUST** use `204 No Content` and **MUST NOT** include a response body." +- id: "7.5" + title: "400 for malformed requests" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 75-400-for-malformed-requests + text: "An operation that accepts path, query, header, or body input **MUST** declare `400 Bad Request` for malformed, unparseable, or structurally invalid input. Well-formed input that violates domain semantics **MUST** use `422` under §7.11." +- id: "7.6" + title: "401 with WWW-Authenticate" + class: M+R + strengths: ["MUST", "SHOULD NOT", "SHOULD"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 76-401-with-www-authenticate + text: "Every operation requiring authentication **MUST** declare `401 Unauthorized` for missing or invalid authentication. The response **MUST** include a `WWW-Authenticate` header (RFC 9110). For an OAuth 2.0 bearer scheme (§13.2), a challenge for an invalid token **SHOULD** carry the RFC 6750 `invalid_token` error; a challenge for a request containing no authentication credentials **SHOULD NOT** include an OAuth error code." +- id: "7.7" + title: "403 when not authorised" + class: R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 77-403-when-not-authorised + text: "Every secured operation that can reject an authenticated caller for insufficient permission **MUST** declare `403 Forbidden`. A BB **MAY** return `404` instead when concealing the existence of a forbidden resource is part of its documented security contract, as allowed by RFC 9110." +- id: "7.8" + title: "404 for missing resources" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 78-404-for-missing-resources + text: "Every operation that addresses a specific resource **MUST** declare `404 Not Found` for an absent resource or for a resource whose existence is intentionally concealed under §7.7. An empty collection **MUST** return `200` with an empty `items` array, not `404`." +- id: "7.9" + title: "409 for state conflicts" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 79-409-for-state-conflicts + text: "An operation that creates a uniquely keyed resource, performs a state transition, or supports idempotent retry **MUST** declare `409 Conflict` when it can conflict with current resource or processing state, including an illegal transition or concurrent in-flight retry under §14.5. A failed conditional request precondition **MUST** use `412` (§7.15), not `409`." +- id: "7.10" + title: "410 for permanent removal" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 710-410-for-permanent-removal + text: "A server **MUST** use `410 Gone` only when it knows that a resource or endpoint has been permanently removed; otherwise it **MUST** use `404`." +- id: "7.11" + title: "422 for semantic errors" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 711-422-for-semantic-errors + text: "A well-formed request that violates domain validation or other semantic constraints **MUST** use `422 Unprocessable Content` (RFC 9110; formerly \"Unprocessable Entity\"). The idempotency-fingerprint use of `422` is in §14.5." +- id: "7.12" + title: "429 for rate limits" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 712-429-for-rate-limits + text: "An operation that enforces a caller-visible rate limit **MUST** declare `429 Too Many Requests` and the headers required by §8.7." +- id: "7.13" + title: "Server errors documented" + class: M + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 713-server-errors-documented + text: "Every operation **MUST** declare `500 Internal Server Error`. Operations exposed through a gateway or dependent service **SHOULD** additionally declare the applicable `502`, `503`, and `504` responses." +- id: "7.14" + title: "All status codes declared" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 714-all-status-codes-declared + text: "Every operation **MUST** declare the status codes it can return. Declaring only `200` is forbidden." +- id: "7.15" + title: "412 for failed preconditions" + class: R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 715-412-for-failed-preconditions + text: "A failed conditional request precondition such as `If-Match` **MUST** use `412 Precondition Failed`." +- id: "7.16" + title: "ETag and If-None-Match" + class: M+R + strengths: ["MUST", "SHOULD", "MAY"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 716-etag-and-if-none-match + text: "Endpoints that return resources **SHOULD** advertise an `ETag` response header derived from the selected representation. `GET` clients **MAY** send `If-None-Match` to receive `304 Not Modified` on no change. An endpoint using ETags for write concurrency **MUST** provide a strong validator suitable for the strong comparison required by `If-Match`." +- id: "7.17" + title: "Optimistic concurrency with If-Match" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 717-optimistic-concurrency-with-if-match + text: "`PUT` and `PATCH` endpoints **SHOULD** support optimistic concurrency: clients send `If-Match: <strong-etag>` and the server returns `412 Precondition Failed` (§7.15) if the resource has changed. An endpoint that requires a conditional write **SHOULD** return `428 Precondition Required` when `If-Match` is absent. A failed `If-Match` precondition **MUST** use `412`, not `409`; `409` (§7.9) is reserved for conflicts not expressed by a conditional precondition." +- id: "7.18" + title: "405 with Allow header" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 718-405-with-allow-header + text: "`405 Method Not Allowed`: the target resource does not support the request method. The response **MUST** include an `Allow` header listing the supported methods (RFC 9110)." +- id: "7.19" + title: "415 for unsupported media types" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 719-415-for-unsupported-media-types + text: "`415 Unsupported Media Type`: the request payload media type is not supported. PATCH endpoints (§6.4) **MUST** return `415` when the request does not use one of the registered patch media types documented by that operation. `406 Not Acceptable` **MAY** be returned when no representation matches the request `Accept` header." +- id: "7.20" + title: "No-store on error responses" + class: M + strengths: ["SHOULD"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 720-no-store-on-error-responses + text: "Error responses (`application/problem+json`) and Operation-status responses (§15) **SHOULD** declare `Cache-Control: no-store`, so a shared cache cannot replay a transient failure or stale operation state. Broader caching behaviour is operational and out of scope (§1.2)." +- id: "7.21" + title: "Schemas for successful response bodies" + class: M + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/7-http-status-codes.md + anchor: 721-schemas-for-successful-response-bodies + text: "Every declared `2xx` response that carries a body **MUST** declare at least one concrete media type and a response schema for each declared media type. An empty schema or description without a schema **MUST NOT** stand in for a response contract. Responses whose HTTP semantics prohibit a body, including `204`, **MUST NOT** declare response content." +- id: "8.1" + title: "Credentials in Authorization header" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 81-credentials-in-authorization-header + text: "Authentication credentials **MUST** travel in the `Authorization` header. They **MUST NOT** appear in query parameters, fragments, or URL paths." +- id: "8.2" + title: "Accept-Language and Content-Language" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 82-accept-language-and-content-language + text: "Localisation requests **MUST** use `Accept-Language`; a localised response **MUST** identify the language actually selected using `Content-Language`, which is not necessarily the request's first preference. A cacheable response selected using `Accept-Language` **MUST** declare `Vary: Accept-Language`." +- id: "8.3" + title: "Idempotency-Key header accepted" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 83-idempotency-key-header-accepted + text: "POST endpoints that require idempotency under §14 **MUST** accept an `Idempotency-Key` header, unless §14.6 applies." +- id: "8.4" + title: "W3C Trace Context correlation" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 84-w3c-trace-context-correlation + text: "Every cross-service HTTP operation **MUST** declare the W3C Trace Context `traceparent` request header and **MAY** declare `tracestate`. A conforming implementation **MUST** propagate a valid received trace context on downstream calls and **MUST** create a valid new context when none is present or the received value is invalid. `tracestate` **MUST NOT** contain personal data. The RFC 9457 `traceId` extension in §11.3 **MUST** equal the 32-hex-digit trace-id component of the request's effective `traceparent`. A separate business or support correlation identifier **MAY** be defined, but **MUST NOT** replace Trace Context." +- id: "8.5" + title: "No new X- prefixed headers" + class: M + strengths: ["MUST NOT", "MAY"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 85-no-new-x--prefixed-headers + text: "New custom headers introduced by this guide or by BBs **MUST NOT** use the `X-` prefix, per RFC 6648. Existing private `X-` headers **MAY** remain only on an unchanged legacy major version and **MUST NOT** be introduced on a new surface or new major version." +- id: "8.6" + title: "No personal data in addressable locations" + class: R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 86-no-personal-data-in-addressable-locations + text: "Personal data (national identifier, phone, email, name, date of birth, exact address) **MUST NOT** appear in path segments, query parameters, or header values other than purpose-specific signed assertions (e.g., an OIDC ID token). Opaque server-generated IDs (§10.1) **MUST** be used to refer to citizen records in URLs." +- id: "8.7" + title: "Rate-limit headers declared" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: OpenAPI + page: part-b/8-headers.md + anchor: 87-rate-limit-headers-declared + text: "An endpoint rate-limited by the BB itself **MUST** document the quota scope and **MUST** declare the `RateLimit` response header using the Structured Field syntax pinned from `draft-ietf-httpapi-ratelimit-headers-11`. A BB that advertises quota-policy details **MUST** use `RateLimit-Policy`. The server **MAY** omit these advisory headers on individual responses as allowed by the draft, but a `429` response **MUST** declare `Retry-After`; when `Retry-After` and `RateLimit` are both present, clients **MUST** treat `Retry-After` as authoritative. Where an API gateway or interoperability mediator owns rate limiting, the BB specification **MUST** state that fact instead of claiming to emit headers it does not control. The legacy `RateLimit-Limit`, `RateLimit-Remaining`, and `RateLimit-Reset` fields **MUST NOT** be described as conforming to the pinned draft." +- id: "9.1" + title: "JSON as default media type" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 91-json-as-default-media-type + text: "Default response media type **MUST** be `application/json` unless the resource is binary or a document export." +- id: "9.2" + title: "camelCase field names" + class: M + strengths: ["SHOULD"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 92-camelcase-field-names + text: "GovStack-owned JSON field names **SHOULD** use `camelCase` consistently within a surface. Fields adopted from an external standard retain that standard's spelling. (See note below on the choice of casing.)" +- id: "9.3" + title: "Real JSON booleans" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 93-real-json-booleans + text: "Boolean fields **MUST** be JSON booleans (`true`/`false`), not strings." +- id: "9.4" + title: "Explicit nullability" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 94-explicit-nullability + text: "Nullability **MUST** be explicit (`type: [..., \"null\"]` per OpenAPI 3.1)." +- id: "9.5" + title: "No spaces or non-ASCII names" + class: M + strengths: ["SHOULD"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 95-no-spaces-or-non-ascii-names + text: "GovStack-owned field names **SHOULD** use ASCII identifiers without spaces. Fields adopted from an external standard retain that standard's spelling." +- id: "9.6" + title: "Avoid abbreviations" + class: R + strengths: ["SHOULD NOT"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 96-avoid-abbreviations + text: "Abbreviations **SHOULD NOT** be used (prefer `quantity` over `qty`)." +- id: "9.7" + title: "Screaming snake case enum values" + class: M + strengths: ["MUST NOT", "SHOULD"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 97-screaming-snake-case-enum-values + text: "Enum values that name a BB-defined state or category **SHOULD** use SCREAMING_SNAKE_CASE (`ACTIVE`, `PENDING_REVIEW`). Values whose form is fixed elsewhere keep the casing their own definition gives them and **MUST NOT** be re-cased to satisfy this rule: identifiers built to a shape this guide defines (event types per §16.3, sort keys per §12.7), codes drawn from an external standard (BCP 47 language tags per §10.9, ISO 4217 currency codes per §10.10), and values registered in an IANA registry, including JOSE and COSE algorithm and curve names such as `ES256`, `EdDSA`, and `Ed25519`, and media types." +- id: "9.8" + title: "Forward-compatible schemas" + class: M + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 98-forward-compatible-schemas + text: "Schemas **MUST** be designed for forward-compatibility: unknown fields and unknown enum values **MUST** be safely ignorable by conforming clients. Schemas **MUST NOT** rely on `additionalProperties: false` at the top level of resource bodies, since that prevents adding fields without a breaking change. Conforming client behaviour (ignoring unknowns) is documented in §18.6 as a non-normative reader expectation." +- id: "9.9" + title: "No closed enums for growing sets" + class: R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 99-no-closed-enums-for-growing-sets + text: "Fields whose value set is expected to grow **MUST NOT** be declared as a closed OpenAPI `enum`, because client code generated from a closed enum typically rejects values added later, which would make the \"adding enum values is non-breaking\" guarantee of §18.3 false in practice. Such fields **MUST** either be declared as an open `type: string` annotated with `x-extensible-enum` (carrying the known values), or define an explicit fallback member (e.g., `UNKNOWN`) that conforming clients map unrecognised values to. Truly fixed value sets (e.g., ISO-defined codes) **MAY** remain closed enums." +- id: "9.10" + title: "GovStack extension prefix" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 910-govstack-extension-prefix + text: "GovStack-defined specification extensions on OpenAPI or AsyncAPI documents **MUST** be prefixed `x-govstack-` (for example, `x-govstack-deprecated` in §18.7, and `x-govstack-api-guide` in §20.3). A prefix rule does not create or standardise an extension; each GovStack extension still requires an explicit schema and governing rule." +- id: "9.11" + title: "Single registered BB code" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: Universal + page: part-c/9-json-conventions-and-naming.md + anchor: 911-single-registered-bb-code + text: "Every namespace that embeds a BB code (HTTP problem-type URLs §11.2, OAuth scopes §13.4, event types §16.3, logical channel IDs §17.2, and any transport-neutral asynchronous error code that embeds one §11.6) **MUST** use the BB's single registered code, identically in all of them. BB codes **MUST** match `^[a-z][a-z0-9-]{1,30}$` and **MUST** be unique across the ecosystem. Until GovStack publishes a canonical register, codes **SHOULD** be agreed through the API Working Group." +- id: "10.1" + title: "Opaque server-generated identifiers" + class: M+R + strengths: ["MUST", "SHOULD", "MAY"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 101-opaque-server-generated-identifiers + text: "Resource identifiers **MUST** be opaque, URL-safe strings, server-generated, and globally unique within the BB. UUID v4 (`format: uuid`) **SHOULD** be the default; ULID, KSUID, or other opaque IDs **MAY** be used where ordering or sortability matters. Clients **MUST** treat all IDs as opaque. **Exception:** registries with statutory identifiers (civil registry numbers, parcel IDs, business numbers, licence numbers) **MAY** use those identifiers in URL paths provided §8.6 is satisfied (the identifier does not constitute personal data; a parcel number or business number is acceptable, a national ID or passport number is not). The opacity requirement applies to BB-generated identifiers; statutory identifiers are by definition not opaque to clients." +- id: "10.2" + title: "RFC 3339 timestamps" + class: M + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 102-rfc-3339-timestamps + text: "Timestamps **MUST** be RFC 3339 in UTC, serialized with the `Z` designator and declared as `format: date-time`. A non-UTC offset **MUST NOT** be used in an API payload; where a local time zone is significant to the consumer, it is carried in a separate field alongside the UTC value. UTC-only is inherited from `govstack-cfr-data#req-2` and cannot be relaxed by a BB specification." +- id: "10.3" + title: "RFC 3339 calendar dates" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 103-rfc-3339-calendar-dates + text: "Dates without time **MUST** be RFC 3339 calendar dates, declared as `format: date`." +- id: "10.4" + title: "Decimal-string monetary amounts" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 104-decimal-string-monetary-amounts + text: "Monetary amounts **MUST** use the object `{ amount: string (decimal), currency: string (ISO 4217) }`. Floats **MUST NOT** be used for money. Decimal-string is chosen over minor-units because GovStack-adopting countries may include currencies with non-decimal subunits and zero-subunit currencies, which a minor-units convention handles inconsistently." +- id: "10.5" + title: "E.164 phone numbers" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 105-e164-phone-numbers + text: "Phone numbers **MUST** be E.164 strings." +- id: "10.6" + title: "RFC 5322 email addresses" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 106-rfc-5322-email-addresses + text: "Email addresses **MUST** be RFC 5322 strings, declared as `format: email`." +- id: "10.7" + title: "Binary uploads and base64 payloads" + class: M+R + strengths: ["MUST", "MAY"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 107-binary-uploads-and-base64-payloads + text: "Large binary uploads **MUST** use `multipart/form-data` or a dedicated binary endpoint. Small inline payloads (signatures, certificates, QR codes, attestations) **MAY** be base64-encoded in JSON bodies; the field **MUST** then be declared with `contentEncoding: base64` and a documented size limit." +- id: "10.8" + title: "ISO 3166-1 country codes" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 108-iso-3166-1-country-codes + text: "Country codes **MUST** be ISO 3166-1 alpha-2." +- id: "10.9" + title: "BCP 47 language codes" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 109-bcp-47-language-codes + text: "Language codes **MUST** be BCP 47." +- id: "10.10" + title: "ISO 4217 currency codes" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 1010-iso-4217-currency-codes + text: "Currency codes **MUST** be ISO 4217." +- id: "10.11" + title: "UTF-8 text encoding" + class: M + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-c/10-data-types-and-formats.md + anchor: 1011-utf-8-text-encoding + text: "Text in API payloads **MUST** be UTF-8. A media type declared anywhere in the specification **MUST NOT** carry a `charset` parameter naming any other encoding. `charset=utf-8` **MAY** be stated explicitly, though it is redundant on JSON media types, whose encoding RFC 8259 already fixes at UTF-8. This is inherited from `govstack-cfr-data#req-1` and cannot be relaxed by a BB specification." +- id: "11.1" + title: "RFC 9457 problem details" + class: M + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-c/11-errors.md + anchor: 111-rfc-9457-problem-details + text: "HTTP `4xx` and `5xx` responses **MUST** use media type `application/problem+json` and the RFC 9457 Problem Details model (RFC 9457 obsoletes RFC 7807 and retains this media type). This rule **MUST NOT** be represented as an RFC 9457 requirement on a non-HTTP message; §11.6 defines the separate asynchronous model." +- id: "11.2" + title: "Stable HTTP problem type URI" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD"] + surface: Universal + page: part-c/11-errors.md + anchor: 112-stable-http-problem-type-uri + text: "Standard RFC 9457 fields `type`, `title`, and `status` **MUST** be present in every GovStack HTTP problem. `type` **MUST** be the sole machine identifier for the problem and **MUST** use `https://govstack.global/problems/{bb-code}/{problem-slug}`. `{bb-code}` is the registered code from §9.11; `{problem-slug}` **MUST** be stable kebab-case, for example `bad-request`, `invalid-field`, or `internal-error`. The URI **SHOULD** dereference to human-readable documentation. A GovStack HTTP Problem **MUST NOT** add a duplicate machine identifier such as `code`.\n`status` **MUST** equal the actual HTTP response status. `detail` and `instance` **SHOULD** be present when they add diagnostic value. The spec **MUST** declare that these fields carry no personal data and no system-internal details such as stack traces, hostnames, or query fragments. HTTP problems **MUST NOT** add a `timestamp`; use response metadata and trace correlation for occurrence diagnostics." +- id: "11.3" + title: "Trace identifier" + class: M + strengths: ["MUST"] + surface: Universal + page: part-c/11-errors.md + anchor: 113-trace-identifier + text: "Every GovStack HTTP problem **MUST** include `traceId`, containing the W3C trace-id defined by §8.4." +- id: "11.4" + title: "Field-level errors array" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-c/11-errors.md + anchor: 114-field-level-errors-array + text: "Where an HTTP failure is attributable to specific request fields, those field-level validation errors **MUST** appear in an `errors` array; each entry **MUST** contain `pointer` (JSON Pointer) and `message`. It **MUST NOT** add a field-level `code`; the enclosing Problem `type` identifies the problem class. The `errors` array is omitted for failures not attributable to a field (for example, an idempotency-key fingerprint mismatch, §14.5)." +- id: "11.5" + title: "Stable HTTP problem fields across languages" + class: R + strengths: ["MUST NOT", "MAY"] + surface: Universal + page: part-c/11-errors.md + anchor: 115-stable-http-problem-fields-across-languages + text: "HTTP Problem `title`, `detail`, and field-error `message` **MAY** be localised. The `type` URI, `status`, `traceId`, `instance`, and field-error `pointer` **MUST NOT** be translated." +- id: "11.6" + title: "Transport-neutral asynchronous errors" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-c/11-errors.md + anchor: 116-transport-neutral-asynchronous-errors + text: "An asynchronous command rejection or processing failure **MUST** use the shared `GovStackAsyncError` schema from `govstack-asyncapi-common.yaml`, with message `contentType: application/json`. This transport-neutral schema remains separate from the HTTP Problem model and **MUST** contain `type` (a stable absolute problem-type URI), `title`, `code`, `traceId`, and `timestamp`; it **MAY** contain `detail` and `errors`. Each asynchronous field error contains `pointer`, `code`, and `message` as defined by the shared `AsyncFieldError` schema. When carried as a structured CloudEvent, this object **MUST** be the event `data`. It **MUST NOT** contain RFC 9457 `status` merely to simulate an HTTP response; a protocol-specific rejection code **MUST** be declared in the applicable binding or as a separately named field whose semantics the BB defines." +- id: "12.1" + title: "Collections must paginate" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 121-collections-must-paginate + text: "Endpoints returning collections **MUST** paginate. Unbounded responses are forbidden. A collection whose size is fixed by the specification itself **MAY** be returned unpaginated, provided the bound is declared in the schema with `maxItems`; an undeclared expectation that a collection stays small does not qualify, because an integrator cannot see it and a linter cannot check it. The standard unversioned endpoints of §5.10 are not collections and this section does not apply to them." +- id: "12.2" + title: "Cursor pagination by default" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 122-cursor-pagination-by-default + text: "Default pagination **MUST** be cursor-based, modelled on Google AIP-158, with optional query parameters `pageSize` and `cursor`. The `cursor` name is used in place of AIP-158's `pageToken`. A cursor **MUST** be URL-safe, opaque, and integrity-protected; base64 encoding of a transparent internal value is not sufficient. It **MUST NOT** contain personal data, grant authority, or bypass authorization on a later request. Clients **MUST NOT** parse or construct cursor values, and servers **MUST** re-authorize every page request. Except for `pageSize`, the filter and sort arguments on a follow-up request **MUST** equal those that produced the cursor; a mismatch, malformed cursor, or expired cursor **MUST** return `400` with a stable Problem `type`. The specification **MUST** document cursor expiry and a deterministic default order with a unique tie-breaker so concurrent records do not create ambiguous page boundaries." +- id: "12.3" + title: "Cursor pagination envelope" + class: M + strengths: ["MUST"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 123-cursor-pagination-envelope + text: "The cursor-pagination response envelope **MUST** be `{ items: [...], pageInfo: { nextCursor, total? } }`. `nextCursor` **MUST** be a non-empty string when another page is available and **MUST** be `null` on the final page; its schema therefore **MUST** declare explicit nullability. Clients determine whether another page is available from `nextCursor` and no separate `hasMore` field is used. `total`, when present, **MUST** state whether it is exact or estimated and whether it reflects the first-page snapshot or the current collection. The `pageInfo` wrapper is inspired by GraphQL Relay Connections but deliberately uses flat `items` and one continuation field." +- id: "12.4" + title: "Documented pageSize bounds" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 124-documented-pagesize-bounds + text: "`pageSize` **MUST** have a documented default and maximum. Specific numeric values are per-BB." +- id: "12.5" + title: "Optional total count" + class: R + strengths: ["MAY"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 125-optional-total-count + text: "`total` **MAY** be omitted when computing it is expensive." +- id: "12.6" + title: "Offset pagination envelope" + class: M+R + strengths: ["MUST", "MAY"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 126-offset-pagination-envelope + text: "Offset pagination **MAY** be used for admin or fixed-size lists. In that case the envelope **MUST** be the flat shape `{ items, offset, limit, total }`, distinct from the cursor `pageInfo` envelope in §12.3; `total` is required here (unlike §12.3, where it is optional)." +- id: "12.7" + title: "Sort parameter convention" + class: M + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 127-sort-parameter-convention + text: "An API that supports sorting **MUST** document the parameter, allowed fields, direction syntax, default order, and a stable tie-breaker. The GovStack default **SHOULD** be `sort`, with `field` for ascending, `-field` for descending, and commas between multiple criteria." +- id: "12.8" + title: "Simple equality filtering" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 128-simple-equality-filtering + text: "Simple equality filtering on non-personal, non-secret fields **SHOULD** use one query parameter per field. A filter containing personal data or another value prohibited from URLs by §8.6 **MUST NOT** use a query parameter and **MUST** use a documented body-based search contract such as §12.9." +- id: "12.9" + title: "Complex filtering via search" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 129-complex-filtering-via-search + text: "Complex filtering and any filtering that contains personal data **SHOULD** use a request body, conventionally at `POST /v1/{collection}/search` per §6.6. When a body-based search is used, pagination parameters (`pageSize`, `cursor`) **MUST** be carried in the request body, and the response **MUST** use the §12.3 envelope. A follow-up request **MUST** retain the same search criteria and sort values as the request that produced its cursor." +- id: "12.10" + title: "Sparse fieldsets out of scope" + class: informative + strengths: [] + surface: OpenAPI + page: part-c/12-pagination-filtering-sorting.md + anchor: 1210-sparse-fieldsets-out-of-scope + text: "Sparse fieldsets (response field selection) are out of scope for v1.0." +- id: "13.1" + title: "Default security on every operation" + class: M + strengths: ["MUST"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 131-default-security-on-every-operation + text: "Each BB API spec **MUST** declare a security scheme block and apply it by default to every operation. On the OpenAPI surface this is a root-level `security` requirement referencing schemes under `components.securitySchemes`. AsyncAPI 3.0 has no root-level `security`: schemes are declared under `components.securitySchemes` and applied on the `servers` and `operations` objects, which together **MUST** cover every operation. Per-operation overrides **MUST** be explicit. The standard unversioned endpoints of §5.10 satisfy this rule with an explicit empty security requirement (`security: []`) where they are unauthenticated: they are exempt from carrying a scheme, not from declaring what they carry." +- id: "13.2" + title: "OAuth and OIDC for citizen operations" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 132-oauth-and-oidc-for-citizen-operations + text: "Citizen-facing protected operations **MUST** declare OAuth 2.0 authorization backed by an OpenID Connect provider. On OpenAPI this is either `type: openIdConnect` with `openIdConnectUrl`, or `type: oauth2` with an `authorizationCode` flow. An API that is purely a resource server, validating access tokens issued by an authorization server it does not own and whose endpoints are not part of its own contract, **MAY** instead declare `type: http` with `scheme: bearer` and `bearerFormat: JWT`, because declaring an `oauth2` flow it does not operate would misdescribe the deployed API. That declaration **MUST** document, in the scheme description or an adjacent `/.well-known/` metadata document, the issuers it accepts and the audience value it requires, so an integrator can still discover how to obtain a usable token. The security-scheme description **MUST** state that the API accepts access tokens and **MUST NOT** treat an OIDC ID Token as an API access token. Authorization-code clients **MUST** use PKCE with `S256` as required by the RFC 9700 security baseline; the resource-owner password grant **MUST NOT** be declared, and the implicit grant **MUST NOT** be declared for a new surface. Reference specifications not tied to a live provider **MAY** use a reserved documentation-domain discovery URL; adopter-specific discovery, authorisation, token, and JWKS endpoints belong in implementation profiles." +- id: "13.3" + title: "Distinct scheme for BB-to-BB calls" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 133-distinct-scheme-for-bb-to-bb-calls + text: "BB-to-BB operations crossing a service-to-service trust boundary, whether routed directly or through an interoperability mediator, **MUST** declare a distinct security scheme appropriate for service-to-service authentication. On OpenAPI this **MUST** be `type: mutualTLS` or an OAuth client-credentials scheme using confidential-client authentication; on AsyncAPI it **MUST** be `type: X509`, OAuth client credentials, or a documented protocol-specific scheme such as SASL. OAuth access tokens **MUST** be audience-restricted to the intended BB and scope-restricted to the operation. Sender-constrained access tokens under RFC 8705 or RFC 9449 **SHOULD** be used across an inter-BB trust boundary. The spec **MUST** distinguish citizen-facing from inter-BB operations." +- id: "13.4" + title: "Namespaced OAuth scopes" + class: M + strengths: ["MUST NOT", "MUST", "SHOULD"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 134-namespaced-oauth-scopes + text: "Where an operation's authorization is scope-based, its OAuth scope strings **MUST** be documented per operation and namespaced so one BB's scopes cannot collide with another's. An API that authorizes on verified token claims rather than scopes **MUST** document, per operation, which claims it requires and what values it accepts; it **MUST NOT** declare scope strings it does not enforce. New GovStack scopes **SHOULD** use `bb:{bb-code}:{resource}:{action}`. The `{bb-code}` segment is the BB's single registered code per §9.11." +- id: "13.5" + title: "Authorization is the credential channel" + class: M+R + strengths: ["MUST NOT"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 135-authorization-is-the-credential-channel + text: "Credentials **MUST NOT** be declared in URL paths, query parameters, cookies, or fragments. The `Authorization` header is the only declared credential channel for token-based schemes." +- id: "13.6" + title: "API keys only for operational endpoints" + class: R + strengths: ["MUST NOT", "MAY"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 136-api-keys-only-for-operational-endpoints + text: "API keys **MAY** be declared on operational endpoints (`/health` and similar per §5.9). They **MUST NOT** be declared on operations that read or write personal data." +- id: "13.7" + title: "Protected transport" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-d/13-authentication-and-authorisation.md + anchor: 137-protected-transport + text: "Every externally reachable OpenAPI server URL and HTTP webhook callback URL **MUST** use `https`. Every AsyncAPI server **MUST** declare a TLS-protected transport or a protocol security binding that provides equivalent confidentiality, integrity, and server authentication. Cleartext transport **MUST NOT** carry credentials, personal data, or GovStack domain events. Negotiated TLS **MUST** be version 1.3 or higher, inherited from `govstack-cfr-security#req-1` and not relaxable by a BB specification. Remaining TLS deployment configuration **MUST** follow RFC 9325 or its successor; concrete certificate trust and cipher configuration are outside this guide." +- id: "14.1" + title: "Idempotency-Key on non-idempotent POSTs" + class: M+R + strengths: ["MUST", "SHOULD", "MAY"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 141-idempotency-key-on-non-idempotent-posts + text: "Except where §14.6 applies, POST endpoints that create resources, move value, submit irreversible requests, send messages, create subscriptions, start long-running jobs, or trigger other non-idempotent processing **MUST** require and accept an `Idempotency-Key` header. Other mutating POST actions **SHOULD** support it unless their naturally idempotent contract documents duplicate handling. Read-like POSTs such as search **MAY** support it. GovStack pins the header syntax and error semantics from `draft-ietf-httpapi-idempotency-key-header-07`; this guide is the stable GovStack profile if that work-in-progress draft changes or expires." +- id: "14.2" + title: "Opaque client-generated keys" + class: R + strengths: ["MUST", "SHOULD"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 142-opaque-client-generated-keys + text: "The key **MUST** be an opaque, client-generated, high-entropy value and **SHOULD** be a UUID. On the wire it **MUST** use the Structured Field String syntax pinned from draft revision 07, including the required quotation marks. The specification **MUST** document the accepted syntax and maximum length, and the server **MUST** reject a malformed, missing-required, or oversized key with `400 Bad Request` before processing the operation." +- id: "14.3" + title: "Documented replay window" + class: R + strengths: ["MUST", "MAY"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 143-documented-replay-window + text: "The spec **MUST** document the idempotency replay-window contract, the required minimum replay window or controlling configuration parameter, and what happens after expiry. Within the documented window the key **MUST** retain the semantics in §14.4 and §14.5; after expiry the server **MAY** process the same key as a new request only if that behaviour is stated explicitly. Concrete retention values belong in implementation profiles." +- id: "14.4" + title: "Replay returns original response" + class: R + strengths: ["MUST"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 144-replay-returns-original-response + text: "A completed repeated request with the same lookup scope, key, and fingerprint within the documented window **MUST** return the original operation result: the same status, body, and result-defining representation headers such as `Content-Type` and `Location`. Per-attempt, temporal, security, tracing, rate-limit, retry, and hop-by-hop headers **MUST** be regenerated or omitted rather than replayed; this includes `Date`, `traceparent`, `tracestate`, `RateLimit`, `Retry-After`, and `Set-Cookie`." +- id: "14.5" + title: "Key reuse and fingerprint mismatch" + class: R + strengths: ["MUST", "SHOULD"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 145-key-reuse-and-fingerprint-mismatch + text: "The server's idempotency lookup scope **MUST** include the effective HTTP method, canonical target URI, key, and, for authenticated operations, the authenticated client and tenant or equivalent authorization partition. The request fingerprint **MUST** include the method, canonical target URI, request content type, canonicalised body, and every documented header that changes operation semantics. Reusing a key in the same lookup scope with a different fingerprint **MUST** return `422 Unprocessable Content`; a matching retry received while the original remains in flight **MUST** return `409 Conflict`. These are GovStack **MUST** requirements even though draft revision 07 expresses the status-code choices as **SHOULD**." +- id: "14.6" + title: "Naturally idempotent designs" + class: R + strengths: ["MUST", "MAY"] + surface: Universal + page: part-d/14-idempotency.md + anchor: 146-naturally-idempotent-designs + text: "Naturally idempotent designs **MAY** satisfy this section without an `Idempotency-Key` header when idempotency is already guaranteed by the resource contract, for example `PUT /v1/resources/{clientProvidedId}` or creation with a documented unique business key that returns the existing resource or a stable conflict on duplicate submission. The spec **MUST** document that duplicate-handling behaviour per operation. Because §10.1 makes BB-generated identifiers server-assigned by default, `PUT`-with-client-id creation is available only where the resource is keyed by a client-supplied or statutory identifier (§10.1)." +- id: "15.1" + title: "202 with Operation Location" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 151-202-with-operation-location + text: "Operations that cannot complete synchronously **MUST** return `202 Accepted` with a `Location` header pointing to an Operation resource and **MUST** return the current Operation representation in the response body." +- id: "15.2" + title: "Local Operation resource shape" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 152-local-operation-resource-shape + text: "A BB that exposes long-running work **MUST** define its Operation schema locally. Its identifier **MUST** be an opaque string and clients **MUST NOT** infer a UUID or any other internal format. The local schema **MUST** document how the identifier, lifecycle state, result, error, and any progress metadata are represented, including when each state-dependent field is present. This guide does not fix those field names or shapes. A shared Operation schema is deferred until multiple BBs demonstrate a stable reusable contract." +- id: "15.3" + title: "Documented Operation lifecycle" + class: M+R + strengths: ["MUST"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 153-documented-operation-lifecycle + text: "The local Operation contract **MUST** distinguish terminal from non-terminal states and **MUST** document the result, error, polling, and cancellation semantics for each applicable state. This guide does not prescribe a status enum or lifecycle model." +- id: "15.4" + title: "Polling the Operation resource" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 154-polling-the-operation-resource + text: "A BB exposing an Operation resource **MUST** make it pollable with `GET` at the URI returned in `Location`. The conventional path **SHOULD** be `/v{major}/operations/{operationId}`. A non-terminal polling response **SHOULD** include `Retry-After` when the server can advise a useful minimum polling interval." +- id: "15.5" + title: "Cancellation via cancel sub-resource" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 155-cancellation-via-cancel-sub-resource + text: "Cancellation, when supported, **MUST** be documented and discoverable from the Operation contract. The conventional action **SHOULD** be `POST /v{major}/operations/{operationId}/cancel`." +- id: "15.6" + title: "Webhook completion notification" + class: R + strengths: ["SHOULD"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 156-webhook-completion-notification + text: "Long-running operations **SHOULD** support completion notification via webhook (§16) rather than requiring clients to poll indefinitely. The threshold at which notification becomes expected is operational and per-BB." +- id: "15.7" + title: "Documented result retention" + class: R + strengths: ["MUST", "SHOULD"] + surface: OpenAPI + page: part-d/15-asynchronous-operations.md + anchor: 157-documented-result-retention + text: "Operation result availability **MUST** be documented as a consumer-visible contract. A reference specification **SHOULD** state the required minimum retention or the configuration parameter that controls it. Concrete retention values belong in implementation profiles." +- id: "16.1" + title: "Event surfaces documented" + class: M+R + strengths: ["MUST"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 161-event-surfaces-documented + text: "Event-driven APIs **MUST** be documented. HTTP push **MUST** use OpenAPI 3.1 `webhooks`; brokered transports and event streams (MQTT, AMQP, Kafka, WebSockets, SSE) **MUST** use AsyncAPI 3.0." +- id: "16.2" + title: "CloudEvents envelope required" + class: M + strengths: ["MUST NOT", "MUST", "SHOULD"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 162-cloudevents-envelope-required + text: "GovStack domain events **MUST** conform to the CloudEvents v1.0.2 specification (CNCF). The event envelope **MUST** include the CloudEvents-required fields `specversion`, `id`, `source`, and `type`; `specversion` **MUST** be the CloudEvents wire value `\"1.0\"` for CloudEvents v1.0.x; `time` and `datacontenttype` **SHOULD** be included; and GovStack-owned domain payload fields **MUST** live under `data`. On the HTTP webhooks surface the event **MUST** be delivered in CloudEvents structured content mode with media type `application/cloudevents+json`; binary content mode **MUST NOT** be used. §17.6 imposes the same requirement on the AsyncAPI surface." +- id: "16.3" + title: "Reverse-DNS event types" + class: M + strengths: ["MUST NOT", "MUST", "SHOULD"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 163-reverse-dns-event-types + text: "Event `type` names **MUST** be stable, globally collision-resistant, and include the BB's registered code from §9.11. New GovStack event types **SHOULD** use the reverse-DNS shape `global.govstack.{bb-code}.{resource}.{action}`. The event type identifies the semantic event kind and **MUST NOT** include the major API version; transport-contract versioning is carried separately (§18.2)." +- id: "16.4" + title: "Stable CloudEvents source" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 164-stable-cloudevents-source + text: "The CloudEvents `source` field **MUST** be a stable, non-empty URI-reference identifying the publishing BB or BB surface; an absolute URI or URN **SHOULD** be used. It **MUST NOT** identify a specific deployment host, pod, broker, queue, or environment." +- id: "16.5" + title: "Optional signed event delivery" + class: R + strengths: ["MAY"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 165-optional-signed-event-delivery + text: "Event delivery **MAY** use message-level signing when the BB's threat model requires authenticity or integrity beyond authenticated transport. Signing is not part of the baseline GovStack event contract. Requirements for an explicitly adopted profile are in §16.6–§16.8." +- id: "16.6" + title: "Signature metadata when used" + class: R + strengths: ["MUST NOT", "MUST", "SHOULD"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 166-signature-metadata-when-used + text: "When the optional profile uses GovStack-owned metadata, an OpenAPI webhook signature **MUST** travel in `GovStack-Signature` and an AsyncAPI transport/application metadata field **MUST** be named `govstackSignature`. When a protocol binding defines a standard signature field, that field **SHOULD** be used and its mapping **MUST** be documented. A surface that does not adopt signing **MUST NOT** require signature metadata." +- id: "16.7" + title: "Replay-detectable signed material" + class: R + strengths: ["MUST"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 167-replay-detectable-signed-material + text: "When signing is adopted, the signed material **MUST** include the event body, the event `id`, and either the CloudEvents `time` value or a signature timestamp, so receivers have the inputs needed to detect replays." +- id: "16.8" + title: "Separate experimental signing profile" + class: R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 168-separate-experimental-signing-profile + text: "Event signing belongs in the separate optional `experimental/govstack-openapi-signing-profile.yaml` artifact, not in either baseline common schema artifact. A BB **MAY** adopt this profile explicitly, but it **MUST** pin the profile version and **MUST** document its key discovery, key rotation, replay policy, protocol mapping, and conformance tests. The profile remains incomplete pending a shared key-discovery and replay-policy contract and **MUST NOT** be presented as an ecosystem-wide baseline." +- id: "16.9" + title: "Readiness for a shared signature profile" + class: informative + strengths: [] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 169-readiness-for-a-shared-signature-profile + text: "A shared signature profile is outside the baseline. It should be considered only after key discovery, key rotation, replay-window enforcement, protocol mappings, conformance test vectors, and interoperable implementations in at least two commonly used GovStack implementation languages exist. Operational signing controls remain outside this guide (§1.2)." +- id: "16.10" + title: "Documented delivery-failure contract" + class: R + strengths: ["MUST", "SHOULD"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 1610-documented-delivery-failure-contract + text: "HTTP webhook delivery-failure behaviour **MUST** be documented per subscription. A reference specification **MUST** define the portable contract fields: whether redelivery is attempted, whether failed deliveries are stored, and how a subscriber can identify or recover failed deliveries. Concrete retry counts, backoff intervals, and failure-store retention values belong in implementation profiles. Failed deliveries **SHOULD** end in a dead-letter queue or equivalent failure store accessible to the subscription owner." +- id: "16.11" + title: "Subscription management interfaces" + class: M+R + strengths: ["MUST", "MAY"] + surface: Event-driven + page: part-d/16-cloudevents-and-webhooks.md + anchor: 1611-subscription-management-interfaces + text: "Subscription management **MUST** expose documented interfaces to create, list, and delete a subscription. If the subscription adopts message signing, it **MUST** also expose or document how to rotate or redistribute the verification material used by the selected profile. HTTP subscription management uses OpenAPI endpoints; brokered or stream-based subscription management **MAY** use message-based commands documented in AsyncAPI if that is the BB's chosen control plane." +- id: "17.1" + title: "Send and receive perspective" + class: M+R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 171-send-and-receive-perspective + text: "Each AsyncAPI document **MUST** define the BB's perspective. An operation with `action: send` means the BB publishes that message to the channel. An operation with `action: receive` means the BB consumes that message from the channel." +- id: "17.2" + title: "Stable logical channel IDs and native addresses" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 172-stable-logical-channel-ids-and-native-addresses + text: "Each entry under AsyncAPI `channels` **MUST** use a stable logical ID and document its mapping to the protocol-native address. New GovStack channel IDs **SHOULD** use `global.govstack.{bb-code}.v{major}.{resource}.{event}`, with the registered code from §9.11. The Channel Object `address` **MUST** use the chosen protocol's native destination syntax, such as an MQTT topic, AMQP routing key, Kafka topic, or WebSocket/SSE path, and **MUST NOT** be forced into reverse-DNS form when that would change protocol semantics." +- id: "17.3" + title: "No personal data in channels" + class: R + strengths: ["MUST NOT"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 173-no-personal-data-in-channels + text: "Logical channel IDs, native addresses, topic names, queue names, routing keys, and channel parameters **MUST NOT** contain personal data, secrets, access tokens, phone numbers, email addresses, national identifiers, names, dates of birth, exact addresses, or other directly identifying attributes. Use opaque IDs or claim-protected payload fields instead." +- id: "17.4" + title: "Declared channel parameters" + class: M+R + strengths: ["MUST", "SHOULD", "MAY"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 174-declared-channel-parameters + text: "Channel parameters **MAY** be used for non-personal routing values such as tenant, ministry, service, region, resource type, or shard. Each parameter **MUST** be declared under the AsyncAPI channel `parameters` object with a non-empty `description` stating its routing semantics. The AsyncAPI 3 Parameter Object carries no `schema` field: a parameter whose permitted values form a closed set **MUST** declare them with `enum`, and one whose values are open **SHOULD** carry `examples`." +- id: "17.5" + title: "No environment names in addresses" + class: M+R + strengths: ["SHOULD NOT"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 175-no-environment-names-in-addresses + text: "Environment names (`dev`, `test`, `prod`), broker implementation names, and deployment-specific prefixes **SHOULD NOT** appear in channel addresses. They belong in `servers`, server variables, broker configuration, or deployment routing unless a protocol profile explicitly requires them." +- id: "17.6" + title: "Structured CloudEvents JSON payloads" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 176-structured-cloudevents-json-payloads + text: "AsyncAPI Message Objects for GovStack domain events **MUST** use `contentType: application/cloudevents+json` and structured CloudEvents JSON: the message payload is the complete CloudEvent, and any GovStack-owned domain data **MUST** live under the CloudEvents `data` field. The base envelope does not require `data` or constrain its JSON shape; each local Message Object makes that decision for its event. This provides one portable, schema-validatable event shape across brokered transports." +- id: "17.7" + title: "Shared CloudEvents envelope schema" + class: M + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 177-shared-cloudevents-envelope-schema + text: "Each BB **MUST** define its Message Objects locally. A domain-event message payload **MUST** compose `#/components/schemas/CloudEventEnvelope` from the pinned `govstack-asyncapi-common.yaml` with a local schema that specialises the event `type` and, when present, `data`. A rejection message **MUST** either reference the shared `GovStackAsyncError` schema directly or use it as CloudEvent `data`. Operation message references **MUST** point to the relevant message entries under the operation's referenced channel, per AsyncAPI 3.0. Security schemes, headers, examples, correlation, and protocol bindings remain local because they require BB- or transport-specific values." +- id: "17.8" + title: "Message headers and idempotency metadata" + class: M+R + strengths: ["MUST NOT", "MUST", "SHOULD", "MAY"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 178-message-headers-and-idempotency-metadata + text: "GovStack-owned transport/application message headers **SHOULD** use camelCase and **MUST NOT** use the `X-` prefix. Structured CloudEvents messages that participate in a distributed trace **MUST** carry the standard CloudEvents distributed-tracing extension attribute `traceparent` and **MAY** carry `tracestate`; workflow metadata **MAY** use the extension attributes `correlationid` and `causationid`. CloudEvents extension names are lowercase. Transport headers **MAY** mirror these values where broker tooling requires it, but the CloudEvent remains normative. If optional signing is adopted, signature metadata **MUST** follow §16.6. Command-like messages that create resources, move value, or trigger non-idempotent processing **MUST** carry an idempotency key: structured CloudEvents commands **MUST** use `idempotencykey`, while non-CloudEvents commands **SHOULD** use `idempotencyKey`." +- id: "17.9" + title: "Message localisation headers" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 179-message-localisation-headers + text: "Message headers used for localisation **MUST** be `acceptLanguage` on inbound command/request messages and `contentLanguage` on outbound localised messages. Stable fields such as identifiers, enum values, timestamps, currency codes, and error codes **MUST NOT** be translated." +- id: "17.10" + title: "Security schemes cover every operation" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1710-security-schemes-cover-every-operation + text: "AsyncAPI security schemes **MUST** be declared under `components.securitySchemes` and applied on `servers`, `operations`, or both so every operation is covered. Optional message signing (§16.5) is message-level integrity and **MUST NOT** be treated as a substitute for broker, server, or operation authentication." +- id: "17.11" + title: "Duplicate delivery contract" + class: R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1711-duplicate-delivery-contract + text: "When the selected protocol or deployment can redeliver a message and consumers need to handle duplicates, the operation **MUST** document the duplicate-handling contract. For CloudEvents, the default duplicate identity is the pair `source` plus `id`. Protocol QoS, acknowledgement, and redelivery fields **MUST** use the applicable AsyncAPI binding when available. Specifications **MUST NOT** claim `effectivelyOnce` as a portable transport guarantee; they **MAY** document application-level idempotency or de-duplication instead." +- id: "17.12" + title: "Ordering only when promised" + class: R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1712-ordering-only-when-promised + text: "Ordering **MUST** be documented only when consumers are allowed to rely on it. When ordering is promised, the applicable protocol binding or operation description **MUST** identify its scope and key, such as a Kafka partition key or an ordered queue. A specification with no ordering promise does not need a placeholder declaration." +- id: "17.13" + title: "Public delivery-management capabilities" + class: R + strengths: ["MUST", "MAY"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1713-public-delivery-management-capabilities + text: "Redelivery, dead-letter handling, retention, and replay **MUST** be documented when they are part of the public contract available to a consumer. The specification **MUST** use the applicable protocol binding, channel configuration, or a linked protocol profile where one exists. Capabilities that are deployment-internal or unavailable to consumers **MAY** be omitted." +- id: "17.14" + title: "Implementation values in protocol profiles" + class: R + strengths: ["MUST NOT", "SHOULD"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1714-implementation-values-in-protocol-profiles + text: "Concrete retry counts, backoff intervals, retention periods, replay windows, and dead-letter store settings **SHOULD** live in protocol or implementation profiles unless a value is a stable promise to every conforming consumer. The core cross-BB specification **MUST NOT** imply that a broker-specific setting is portable across protocols." +- id: "17.15" + title: "No universal delivery extensions" + class: R + strengths: ["MUST NOT", "MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1715-no-universal-delivery-extensions + text: "`govstack-asyncapi-common.yaml` does not define universal delivery, ordering, redelivery, dead-letter, retention, or replay extensions. A specification **MUST** use standard AsyncAPI bindings first and **MUST** state any remaining consumer-visible promise in `description` or a linked protocol profile. The presence of a custom extension alone **MUST NOT** be treated as an interoperable delivery contract." +- id: "17.16" + title: "Async rejection error messages" + class: M+R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1716-async-rejection-error-messages + text: "Command-like messages that can be rejected asynchronously **MUST** define a rejection or failure message using `GovStackAsyncError` from §11.6, not an artificial RFC 9457 HTTP `status`. The error message **MUST** be correlated to the original message using §17.8 or an equivalent protocol binding." +- id: "17.17" + title: "Declared request-reply correlation" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1717-declared-request-reply-correlation + text: "Request-reply over messaging **MAY** be used where the protocol and use case support it. When used, the AsyncAPI operation **MUST** declare the reply channel or reply address pattern and the correlation mechanism. Fire-and-forget event publication **MUST NOT** pretend to be request-reply." +- id: "17.18" + title: "Correlated completion signals" + class: M+R + strengths: ["MUST"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1718-correlated-completion-signals + text: "Long-running asynchronous work triggered by a message **MUST** expose completion through either an operation-status message based on the §15 Operation resource or an operation-completed domain event. The spec **MUST** document how clients correlate the completion signal to the initiating message." +- id: "17.19" + title: "Protocol bindings where relevant" + class: M+R + strengths: ["MUST", "SHOULD"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1719-protocol-bindings-where-relevant + text: "Protocol bindings **MUST** be present where protocol-specific fields affect interoperability. At minimum, Kafka-like bindings **SHOULD** declare topic and key semantics; MQTT bindings **SHOULD** declare QoS and retained-message policy; AMQP bindings **SHOULD** declare exchange, queue, and routing-key semantics; WebSocket and SSE bindings **SHOULD** declare connection and message framing. Detailed broker operations remain out of scope for this guide." +- id: "17.20" + title: "Representative message examples" + class: M+R + strengths: ["SHOULD"] + surface: AsyncAPI + page: part-d/17-asyncapi-channel-rules.md + anchor: 1720-representative-message-examples + text: "AsyncAPI documents **SHOULD** define representative message examples, including headers plus payload where the interaction is not obvious from the schema. Examples for command, event, error, and operation-completion families are especially useful, but filler examples are not required." +- id: "18.1" + title: "SemVer versioning" + class: M + strengths: ["MUST"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 181-semver-versioning + text: "`info.version` **MUST** follow SemVer and identify the version of that surface's published contract, not the implementation version. When a surface also exposes a major version in a path, channel, or protocol field, the two **MUST** agree. An implementation may therefore be at `0.16.3` while the contract it serves is at `1.4.0`." +- id: "18.2" + title: "Major version in path or channel" + class: M + strengths: ["MUST NOT", "MUST", "SHOULD"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 182-major-version-in-path-or-channel + text: "A major version increment **MUST** be visible in the canonical contract through the surface's declared versioning mechanism. New GovStack OpenAPI surfaces **SHOULD** carry it in each versioned path key (`/v2/`) rather than duplicating it in `servers`. New AsyncAPI surfaces **SHOULD** carry it in the logical channel ID defined by §17.2. Protocol-native addresses **MUST NOT** be rewritten solely to carry a guide-specific version shape." +- id: "18.3" + title: "Backward-compatible minor changes" + class: M+R + strengths: ["MUST NOT", "MUST", "MAY"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 183-backward-compatible-minor-changes + text: "Compatibility **MUST** be evaluated as an existing client communicating with a newer server, separately for input and output. A patch increment **MUST** be limited to a backward-compatible correction that does not add public functionality. A minor increment **MAY** add an endpoint; add an optional request field whose absence preserves the old behaviour; broaden values accepted in a request; or add a response field that conforming clients ignore under §9.8. A response enum value **MAY** be added only when the field was declared extensibly under §9.9. For messaging, the same test **MUST** be applied from publisher to existing consumer for sent messages and from existing publisher to consumer for received messages. Additive syntax **MUST NOT** be called compatible when it changes defaults, pagination boundaries, ordering, authorization, delivery guarantees, or other observable semantics." +- id: "18.4" + title: "Breaking changes bump major version" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 184-breaking-changes-bump-major-version + text: "A breaking change **MUST** be released as a new major version. Breaking changes include removing or renaming an endpoint, operation, message, field, or enum value; adding a required request field; rejecting a previously accepted request; widening the type, length, format, or closed-enum values a server may emit beyond the old response schema; ceasing to emit a required response field; changing a success status, media type, default, identifier construction, field presence, pagination or sort behaviour, error-code meaning, idempotency behaviour, security requirement or scope, event meaning, delivery guarantee, or ordering guarantee. Moving a contract component in a way that breaks generated-client references **MUST** also be treated as breaking even when the serialized wire shape is unchanged." +- id: "18.5" + title: "Deprecation and Sunset headers" + class: M+R + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 185-deprecation-and-sunset-headers + text: "Deprecated HTTP endpoints **MUST** return a `Deprecation` header per RFC 9745 (a Structured Field date carrying the deprecation timestamp, for example `Deprecation: @1735689600`) and a `Link` with relation `deprecation` pointing to migration documentation. When removal is planned, they **MUST** additionally return a `Sunset` header per RFC 8594; its timestamp **MUST NOT** precede the deprecation timestamp. The GovStack governance process owns the minimum deprecation window and maximum number of concurrent major versions." +- id: "18.6" + title: "Clients ignore unknown fields" + class: informative + strengths: [] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 186-clients-ignore-unknown-fields + text: "(informative) Conforming clients are expected to ignore unknown JSON fields and unknown enum values. This is what makes the additive changes in 18.3 non-breaking; the design constraint that enables it is in §9.8." +- id: "18.7" + title: "AsyncAPI deprecation metadata" + class: M+R + strengths: ["MUST", "MAY"] + surface: Universal + page: part-d/18-compatibility-and-lifecycle.md + anchor: 187-asyncapi-deprecation-metadata + text: "AsyncAPI channels, operations, and messages **MUST** declare consumer-visible deprecation and replacement guidance in their `description`. They **MAY** also use the experimental `x-govstack-deprecated` extension with `since`, `sunset`, `replacement`, and `reason` where supported by tooling." +- id: "19.1" + title: "Honour the request language" + class: R + strengths: ["MUST"] + surface: Universal + page: part-e/19-localisation.md + anchor: 191-honour-the-request-language + text: "Localisable content (error `title`/`detail`, enum display labels, free-text status messages) **MUST** respect the request language header appropriate to the surface: `Accept-Language` for HTTP, `acceptLanguage` for AsyncAPI messages." +- id: "19.2" + title: "Never translate stable content" + class: R + strengths: ["MUST NOT"] + surface: Universal + page: part-e/19-localisation.md + anchor: 192-never-translate-stable-content + text: "Stable content (HTTP Problem `type`, transport-neutral asynchronous error `code`, enum values, identifiers, timestamps, currency codes) **MUST NOT** be translated." +- id: "19.3" + title: "Declared default language" + class: R + strengths: ["MUST", "SHOULD"] + surface: Universal + page: part-e/19-localisation.md + anchor: 193-declared-default-language + text: "The specification **MUST** declare its default response language. English **SHOULD** be used as the cross-border fallback when deployment policy or law does not select another default." +- id: "19.4" + title: "Declare the response language" + class: M+R + strengths: ["MUST"] + surface: Universal + page: part-e/19-localisation.md + anchor: 194-declare-the-response-language + text: "Responses or messages with localised content **MUST** include the response language header appropriate to the surface: `Content-Language` for HTTP, `contentLanguage` for AsyncAPI messages." +- id: "20.1" + title: "Every file passes validation" + class: M + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-e/20-conformance-and-validation.md + anchor: 201-every-file-passes-validation + text: "Every canonical OpenAPI entrypoint **MUST** pass `openapi-spec-validator` for its declared qualified 3.1 patch, with local references resolved. Every canonical AsyncAPI entrypoint **MUST** pass an AsyncAPI 3.0 parser/validator such as `@asyncapi/parser`. A referenced schema fragment **MUST** validate against its own declared schema dialect and **MUST NOT** be rejected merely because it is not a standalone OpenAPI or AsyncAPI document." +- id: "20.2" + title: "Passes the GovStack Spectral ruleset" + class: M + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-e/20-conformance-and-validation.md + anchor: 202-passes-the-govstack-spectral-ruleset + text: "Every BB API spec **MUST** pass the exact GovStack Spectral ruleset version declared under §20.3 for the machine-checkable rules applicable to its surface. The machine-checkable rules are those tagged `[M]`, together with the mechanical portion of `[M+R]` rules (§1.9). Ruleset `0.1.0-draft` **MUST** cover the OpenAPI, CloudEvents, and AsyncAPI documentation rules recorded in its coverage manifest. Validation **MUST** fail when the declared ruleset artifact is unavailable or differs from the exact declared version; tooling **MUST NOT** select “latest” or fall back by major/minor compatibility." +- id: "20.3" + title: "Declared guide conformance version" + class: M + strengths: ["MUST NOT", "MUST"] + surface: Universal + page: part-e/20-conformance-and-validation.md + anchor: 203-declared-guide-conformance-version + text: "Each canonical specification file **MUST** declare the exact guide and ruleset versions it targets in the `info`-level `x-govstack-api-guide` object using `version` and `rulesetVersion`, each an exact SemVer value rather than a range. For this draft both values **MUST** be `0.1.0-draft`. An optional `exceptions` array **MUST** contain objects with exactly these fields: `rule` (guide rule ID), `scope` (RFC 6901 JSON Pointer into this canonical document), `rationale` (non-empty explanation), `record` (absolute HTTPS URI for the approved public record), `reviewedBy` (non-empty approving authority), `reviewedAt` (calendar date `YYYY-MM-DD`), and `expiresAt` (calendar date `YYYY-MM-DD`). An exception **MUST** suppress only its named rule at or below its declared scope. An expired entry, invalid field, or exception not approved under §1.6 **MUST** fail validation rather than suppress the rule. Offline validation **MUST NOT** require dereferencing the record URI." diff --git a/api-design-guide/tools/build_rules_index.py b/api-design-guide/tools/build_rules_index.py new file mode 100644 index 0000000..75cc75a --- /dev/null +++ b/api-design-guide/tools/build_rules_index.py @@ -0,0 +1,482 @@ +#!/usr/bin/env python3 +"""Build the machine-readable rules index for the GovStack Cross-BB API Design Guide. + +This script is part of the guide's machine layer. It scans every rule page under +`part-*/` and regenerates two artifacts at the book root: + + * `rules.yaml` - one structured entry per rule (id, class, strengths, + surface, page, anchor, and rule text). + * `all-rules.md` - a human-facing "rules at a glance" page with one GFM + table per section. + +Both artifacts are GENERATED. Do not hand-edit them; edit the section pages and +re-run this script instead. Output is byte-reproducible: no timestamps, no +machine-specific paths, stable ordering. + +Modes: + (default) regenerate both files on disk and report what changed. + --check regenerate in memory and diff against disk; exit 1 if either file + is stale (a missing file counts as stale), 0 otherwise. + +The script fails loudly (non-zero exit, message on stderr) on any malformed +page: an anchor id that does not equal the computed slug, an href that does not +equal its id, a duplicate rule id, a rule id whose section does not match its +page filename, or an unrecognised surface. +""" + +import argparse +import difflib +import json +import re +import sys +from pathlib import Path + +GUIDE_NAME = "GovStack Cross-BB API Design Guide" +GUIDE_VERSION = "0.1.0-draft" + +# Regex for a heading line carrying an explicit anchor tag, e.g. +# ## 2.1 OpenAPI 3.1.0 required <a href="#21-openapi-310-required" id="21-openapi-310-required"></a> +ANCHORED_HEADING_RE = re.compile( + r'^## (?P<text>.+?) <a href="#(?P<href>[^"]*)" id="(?P<id>[^"]*)"></a>\s*$' +) +# A rule heading's leading text is "<section>.<index> <short title>". +RULE_TEXT_RE = re.compile(r"^(?P<id>\d+\.\d+) (?P<title>.+)$") +# A line that looks like the start of a rule heading (used to detect a rule +# heading whose anchor is missing or malformed). +LOOKS_LIKE_RULE_RE = re.compile(r"^## \d+\.\d+(\s|$)") +# Enforcement-class badge at the very start of a rule body. +BADGE_RE = re.compile(r"^\*\*\[(?P<cls>M\+R|M|R)\]\*\* ") +# RFC 2119 strength keywords, in output order. Longer forms are listed before +# their prefixes so "MUST NOT" is considered before "MUST". +STRENGTH_TOKENS = [ + ("MUST NOT", "**MUST NOT**"), + ("MUST", "**MUST**"), + ("SHOULD NOT", "**SHOULD NOT**"), + ("SHOULD", "**SHOULD**"), + ("MAY", "**MAY**"), +] + +SLUG_KEEP = set("abcdefghijklmnopqrstuvwxyz0123456789-") + + +class BuildError(Exception): + """Raised when a page violates the format contract.""" + + +def slugify(heading_text): + """GitHub-style slug shared with check_links.py; the two MUST be identical. + + Lowercase the text, keep ASCII letters, digits and existing hyphens, turn + spaces into hyphens, drop every other character. Consecutive hyphens are NOT + collapsed. The caller passes the heading text without its trailing `<a>` tag + and with the trailing space before that tag already stripped. + """ + out = [] + for ch in heading_text.lower(): + if ch in SLUG_KEEP: + out.append(ch) + elif ch == " ": + out.append("-") + # every other character is dropped + return "".join(out) + + +def unwrap_links(text): + """Replace every markdown link `[text](target)` with its link text. + + The scanner tracks bracket and paren depth so link text that itself contains + nested brackets is handled correctly. + """ + result = [] + i = 0 + n = len(text) + while i < n: + if text[i] == "[": + depth = 0 + j = i + close = -1 + while j < n: + if text[j] == "[": + depth += 1 + elif text[j] == "]": + depth -= 1 + if depth == 0: + close = j + break + j += 1 + if close != -1 and close + 1 < n and text[close + 1] == "(": + k = close + 2 + pdepth = 1 + while k < n: + if text[k] == "(": + pdepth += 1 + elif text[k] == ")": + pdepth -= 1 + if pdepth == 0: + break + k += 1 + if k < n and pdepth == 0: + link_text = text[i + 1 : close] + result.append(unwrap_links(link_text)) + i = k + 1 + continue + result.append(text[i]) + i += 1 + else: + result.append(text[i]) + i += 1 + return "".join(result) + + +def strip_example_blocks(body_lines): + """Drop each informative Example block: from a line starting with + `**Example (informative).**` through the end of its fenced code block.""" + kept = [] + in_example = False + fence_open = False + for line in body_lines: + if not in_example: + if line.startswith("**Example (informative).**"): + in_example = True + fence_open = False + continue + kept.append(line) + else: + if line.lstrip().startswith("```"): + if not fence_open: + fence_open = True + else: + in_example = False + fence_open = False + # every line inside the example is dropped + return kept + + +def group_paragraphs(lines): + """Collapse soft-wrapped lines into paragraphs; blank lines separate them.""" + paragraphs = [] + current = [] + for line in lines: + if line.strip() == "": + if current: + paragraphs.append(" ".join(current)) + current = [] + else: + current.append(line.strip()) + if current: + paragraphs.append(" ".join(current)) + return paragraphs + + +def extract_strengths(text): + """RFC 2119 keywords present in the rule text, deduplicated, in fixed order.""" + strengths = [] + for label, token in STRENGTH_TOKENS: + if token in text: + strengths.append(label) + return strengths + + +def strongest_strength(strengths): + """The single strongest keyword for the summary table.""" + if "MUST NOT" in strengths or "MUST" in strengths: + return "MUST" + if "SHOULD NOT" in strengths or "SHOULD" in strengths: + return "SHOULD" + if "MAY" in strengths: + return "MAY" + return "—" # em dash placeholder rendered as a single character + + +def parse_body(body_lines): + """Return (class, strengths, text) for one rule body.""" + kept = strip_example_blocks(body_lines) + paragraphs = [unwrap_links(p) for p in group_paragraphs(kept)] + rule_class = "informative" + if paragraphs: + match = BADGE_RE.match(paragraphs[0]) + if match: + rule_class = match.group("cls") + paragraphs[0] = paragraphs[0][match.end() :] + text = "\n".join(paragraphs) + return rule_class, extract_strengths(text), text + + +def parse_section_number(filename): + match = re.match(r"^(\d+)-", filename) + if not match: + raise BuildError(f"cannot parse a section number from filename: {filename}") + return int(match.group(1)) + + +def extract_h1(lines, page_rel): + for line in lines: + match = re.match(r"^# (.+)$", line) + if match: + title = match.group(1).strip() + return re.sub(r"\s*<a href.*</a>\s*$", "", title) + raise BuildError(f"{page_rel}: no H1 heading found") + + +def compute_surface(lines, page_rel): + for line in lines: + stripped = line.strip() + if stripped.startswith("**Applies to:**"): + rest = stripped[len("**Applies to:**") :].strip() + if rest.startswith("OpenAPI surface"): + return "OpenAPI" + if rest.startswith("AsyncAPI surface"): + return "AsyncAPI" + if rest.startswith("Event-driven"): + return "Event-driven" + if rest.startswith("Universal"): + return "Universal" + raise BuildError( + f"{page_rel}: unrecognised surface in 'Applies to:' line: {rest!r}" + ) + raise BuildError(f"{page_rel}: no '**Applies to:**' line found in hint block") + + +def discover_rule_pages(book_root): + pages = list(book_root.glob("part-*/*.md")) + return sorted(pages, key=lambda p: (parse_section_number(p.name), p.name)) + + +def heading_indices(lines): + return [i for i, line in enumerate(lines) if line.startswith("## ")] + + +def collect_page(book_root, page, seen_ids): + """Parse one rule page into a page-info dict with its ordered rule list.""" + page_rel = page.relative_to(book_root).as_posix() + section_num = parse_section_number(page.name) + lines = page.read_text(encoding="utf-8").split("\n") + h1_title = extract_h1(lines, page_rel) + surface = compute_surface(lines, page_rel) + all_heads = heading_indices(lines) + + rules = [] + for idx, line in enumerate(lines): + if not line.startswith("## "): + continue + match = ANCHORED_HEADING_RE.match(line) + if not match: + if LOOKS_LIKE_RULE_RE.match(line): + raise BuildError( + f"{page_rel}:{idx + 1}: rule heading is missing a valid anchor tag: {line.strip()!r}" + ) + # A non-rule heading without an anchor: nothing to validate here. + continue + + heading_text = match.group("text") + href = match.group("href") + anchor_id = match.group("id") + expected = slugify(heading_text) + if anchor_id != expected: + raise BuildError( + f"{page_rel}:{idx + 1}: anchor id {anchor_id!r} != computed slug {expected!r} " + f"for heading {heading_text!r}" + ) + if href != anchor_id: + raise BuildError( + f"{page_rel}:{idx + 1}: href '#{href}' != id '{anchor_id}'" + ) + + rule_match = RULE_TEXT_RE.match(heading_text) + if not rule_match: + # A validated non-rule anchored heading (for example a "Note on ..." + # heading). It is not part of the rules index. + continue + + rule_id = rule_match.group("id") + rule_title = rule_match.group("title").strip() + if rule_id in seen_ids: + raise BuildError(f"{page_rel}:{idx + 1}: duplicate rule id {rule_id!r}") + seen_ids.add(rule_id) + if int(rule_id.split(".")[0]) != section_num: + raise BuildError( + f"{page_rel}:{idx + 1}: rule id {rule_id!r} does not match page " + f"section number {section_num}" + ) + + # Body runs from after this heading to the next '## ' heading or EOF. + next_heads = [h for h in all_heads if h > idx] + end = next_heads[0] if next_heads else len(lines) + body_lines = lines[idx + 1 : end] + rule_class, strengths, text = parse_body(body_lines) + + rules.append( + { + "id": rule_id, + "title": rule_title, + "class": rule_class, + "strengths": strengths, + "surface": surface, + "page": page_rel, + "anchor": anchor_id, + "text": text, + } + ) + return {"page": page_rel, "h1_title": h1_title, "rules": rules} + + +def collect(book_root): + seen_ids = set() + page_infos = [] + for page in discover_rule_pages(book_root): + page_infos.append(collect_page(book_root, page, seen_ids)) + rules = [rule for info in page_infos for rule in info["rules"]] + return rules, page_infos + + +def js(value): + """A JSON string literal, which is also a valid YAML double-quoted scalar.""" + return json.dumps(value, ensure_ascii=False) + + +def inline_list(items): + if not items: + return "[]" + return "[" + ", ".join(js(item) for item in items) + "]" + + +def render_rules_yaml(rules): + lines = [ + "# GENERATED FILE. DO NOT HAND-EDIT.", + "# Regenerate with: python3 tools/build_rules_index.py", + "#", + "# Invariant: for each rule, `page` + `anchor` locate it in the book.", + "# The same `#anchor` fragment resolves on both GitHub and GitBook.", + f"guide: {GUIDE_NAME}", + f"version: {GUIDE_VERSION}", + f"rule_count: {len(rules)}", + "rules:", + ] + for rule in rules: + lines.append(f"- id: {js(rule['id'])}") + lines.append(f" title: {js(rule['title'])}") + lines.append(f" class: {rule['class']}") + lines.append(f" strengths: {inline_list(rule['strengths'])}") + lines.append(f" surface: {rule['surface']}") + lines.append(f" page: {rule['page']}") + lines.append(f" anchor: {rule['anchor']}") + lines.append(f" text: {js(rule['text'])}") + return "\n".join(lines) + "\n" + + +def escape_cell(text): + return text.replace("\\", "\\\\").replace("|", "\\|") + + +def render_all_rules_md(page_infos): + lines = [ + "---", + 'description: "Every rule in the guide: enforcement class, RFC 2119 strength, surface, and a link."', + "---", + "", + "# Rules at a glance", + "", + "This page is generated from the section pages by `tools/build_rules_index.py`; " + "do not edit it by hand. Class legend: `[M]` machine-checkable, `[R]` review, " + "`[M+R]` both; see [§1.9](1-introduction.md#19-rule-enforcement-classes).", + "", + ] + for info in page_infos: + if not info["rules"]: + continue + lines.append(f"## {info['h1_title']}") + lines.append("") + lines.append("| Rule | Class | Strength | Surface | Title |") + lines.append("| --- | --- | --- | --- | --- |") + for rule in info["rules"]: + rule_cell = f"[{rule['id']}]({rule['page']}#{rule['anchor']})" + class_cell = "—" if rule["class"] == "informative" else rule["class"] + strength_cell = strongest_strength(rule["strengths"]) + surface_cell = rule["surface"] + title_cell = escape_cell(rule["title"]) + lines.append( + f"| {rule_cell} | {class_cell} | {strength_cell} | {surface_cell} | {title_cell} |" + ) + lines.append("") + return "\n".join(lines).rstrip("\n") + "\n" + + +def write_lf(path, content): + with path.open("w", encoding="utf-8", newline="\n") as handle: + handle.write(content) + + +def run_check(targets, rule_count): + mismatch = False + for path, content in targets: + if not path.exists(): + print( + f"{path.name}: MISSING (run: python3 tools/build_rules_index.py)", + file=sys.stderr, + ) + mismatch = True + continue + disk = path.read_text(encoding="utf-8") + if disk != content: + mismatch = True + diff = difflib.unified_diff( + disk.splitlines(), + content.splitlines(), + fromfile=f"{path.name} (on disk)", + tofile=f"{path.name} (regenerated)", + lineterm="", + ) + print("\n".join(diff), file=sys.stderr) + if mismatch: + print( + "check failed: generated artifacts are stale; run python3 tools/build_rules_index.py", + file=sys.stderr, + ) + return 1 + print(f"check passed: rules.yaml and all-rules.md are up to date ({rule_count} rules)") + return 0 + + +def run_write(targets, page_infos, rule_count): + for path, content in targets: + if not path.exists(): + status = "created" + elif path.read_text(encoding="utf-8") != content: + status = "updated" + else: + status = "unchanged" + write_lf(path, content) + print(f"{status}: {path.name}") + page_count = len([info for info in page_infos if info["rules"]]) + print(f"wrote {rule_count} rules across {page_count} pages") + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--check", + action="store_true", + help="regenerate in memory and fail (exit 1) if the on-disk files differ", + ) + args = parser.parse_args(argv) + + book_root = Path(__file__).resolve().parent.parent + try: + rules, page_infos = collect(book_root) + except BuildError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + + targets = [ + (book_root / "rules.yaml", render_rules_yaml(rules)), + (book_root / "all-rules.md", render_all_rules_md(page_infos)), + ] + + if args.check: + return run_check(targets, len(rules)) + return run_write(targets, page_infos, len(rules)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api-design-guide/tools/check_links.py b/api-design-guide/tools/check_links.py new file mode 100644 index 0000000..e67cf46 --- /dev/null +++ b/api-design-guide/tools/check_links.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Validate the cross-reference integrity of the GovStack Cross-BB API Design Guide. + +This script is part of the guide's machine layer. It reads every `.md` file +under the book root (including `guides/`, `appendix/`, `SUMMARY.md` and +`README.md`, but excluding `linter/`, which is tooling rather than book +content) and enforces the book's internal-consistency contract: + + 1. Every relative markdown link resolves to a file that exists. + 2. Every link fragment `#x` matches an explicit anchor id in the target file. + 3. Every explicit `<a href="#x" id="x"></a>` anchor has href == id, id equal to + the slug of its heading, and no duplicate id within a page. + 4. SUMMARY.md lists every page exactly once (README.md first) and nothing that + is missing. + 5. No page is an orphan (unreachable from SUMMARY.md). + 6. Every page opens with `---`, carries a double-quoted `description:` line, and + closes its frontmatter. + +Exit status is 0 with a one-line summary when everything passes, or 1 with every +failure listed on stderr. There are no silent skips: if a check cannot run (for +example the open-questions appendix is missing) that itself is a failure. +""" + +import argparse +import os +import re +import sys +from pathlib import Path + +# Explicit anchor tag as emitted on heading lines. +ANCHOR_RE = re.compile(r'<a href="#(?P<href>[^"]*)" id="(?P<id>[^"]*)"></a>') +DESCRIPTION_RE = re.compile(r'^description:\s*".*"\s*$') + +SKIP_LINK_PREFIXES = ("http://", "https://", "mailto:") +SLUG_KEEP = set("abcdefghijklmnopqrstuvwxyz0123456789-") + +def slugify(heading_text): + """GitHub-style slug shared with build_rules_index.py; the two MUST be identical. + + Lowercase the text, keep ASCII letters, digits and existing hyphens, turn + spaces into hyphens, drop every other character. Consecutive hyphens are NOT + collapsed. The caller passes the heading text without its trailing `<a>` tag + and with the trailing space before that tag already stripped. + """ + out = [] + for ch in heading_text.lower(): + if ch in SLUG_KEEP: + out.append(ch) + elif ch == " ": + out.append("-") + # every other character is dropped + return "".join(out) + + +def iter_markdown_links(line): + """Yield (target, column) for every `[text](target)` link on a line. + + A bracket/paren depth scanner is used so nested link text is parsed + correctly. + """ + i = 0 + n = len(line) + while i < n: + if line[i] == "[": + depth = 0 + j = i + close = -1 + while j < n: + if line[j] == "[": + depth += 1 + elif line[j] == "]": + depth -= 1 + if depth == 0: + close = j + break + j += 1 + if close != -1 and close + 1 < n and line[close + 1] == "(": + k = close + 2 + pdepth = 1 + while k < n: + if line[k] == "(": + pdepth += 1 + elif line[k] == ")": + pdepth -= 1 + if pdepth == 0: + break + k += 1 + if k < n and pdepth == 0: + yield line[close + 2 : k].strip(), i + i = k + 1 + continue + i += 1 + else: + i += 1 + + +class Checker: + def __init__(self, book_root): + self.book_root = book_root + self.failures = [] + # linter/ is lint tooling, not book content: its markdown (own READMEs, + # node_modules) is not pages. Links from pages into linter/ still get + # their existence checked like any other target. + self.md_files = sorted( + p + for p in book_root.rglob("*.md") + if p.relative_to(book_root).parts[0] != "linter" + ) + # Per-file data keyed by absolute Path. + self.anchors = {} # path -> set of anchor ids + self.links = [] # (path, lineno, target) + self.link_count = 0 + self.anchor_count = 0 + + def rel(self, path): + return path.relative_to(self.book_root).as_posix() + + def fail(self, path, lineno, message): + location = self.rel(path) + if lineno is not None: + location += f":{lineno}" + self.failures.append(f"{location}: {message}") + + # -- pass 1: read each file, validate anchors and frontmatter ------------- + + def scan_files(self): + for path in self.md_files: + lines = path.read_text(encoding="utf-8").split("\n") + is_summary = self.rel(path) == "SUMMARY.md" + if not is_summary: + self.check_frontmatter(path, lines) + self.collect_anchors(path, lines) + self.collect_links(path, lines) + + def check_frontmatter(self, path, lines): + if not lines or lines[0].strip() != "---": + self.fail(path, 1, "page does not start with '---' frontmatter") + return + close_idx = None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + close_idx = i + break + if close_idx is None: + self.fail(path, 1, "frontmatter is never closed with '---'") + return + for i in range(1, close_idx): + if DESCRIPTION_RE.match(lines[i]): + return + self.fail( + path, + 1, + 'frontmatter has no double-quoted description: line (description: "...")', + ) + + def collect_anchors(self, path, lines): + ids = set() + for lineno, line in enumerate(lines, start=1): + for match in ANCHOR_RE.finditer(line): + self.anchor_count += 1 + href = match.group("href") + anchor_id = match.group("id") + pre = line[: match.start()] + heading_text = pre.lstrip("#").strip() + expected = slugify(heading_text) + if href != anchor_id: + self.fail( + path, + lineno, + f"anchor href '#{href}' != id '{anchor_id}'", + ) + if anchor_id != expected: + self.fail( + path, + lineno, + f"anchor id '{anchor_id}' != slug '{expected}' of heading " + f"{heading_text!r}", + ) + if anchor_id in ids: + self.fail(path, lineno, f"duplicate anchor id '{anchor_id}'") + ids.add(anchor_id) + self.anchors[path] = ids + + def collect_links(self, path, lines): + for lineno, line in enumerate(lines, start=1): + for target, _col in iter_markdown_links(line): + self.links.append((path, lineno, target)) + + # -- pass 2: validate links and fragments --------------------------------- + + def validate_links(self): + for path, lineno, target in self.links: + # Skip external links and empty targets. Bare "#fragment" self-links + # never match these and fall through to the fragment check below. + if target == "" or any(target.startswith(p) for p in SKIP_LINK_PREFIXES): + continue + self.link_count += 1 + if target.startswith("#"): + self.validate_fragment(path, lineno, path, target[1:]) + continue + path_part, _, fragment = target.partition("#") + if path_part == "": + self.validate_fragment(path, lineno, path, fragment) + continue + resolved = Path(os.path.normpath(path.parent / path_part)) + if not resolved.exists(): + self.fail( + path, + lineno, + f"link target does not exist: {target}", + ) + continue + if fragment: + self.validate_fragment(path, lineno, resolved, fragment) + + def validate_fragment(self, path, lineno, target_file, fragment): + if fragment == "": + return + if target_file.suffix != ".md": + self.fail( + path, + lineno, + f"fragment '#{fragment}' points at a non-markdown file " + f"{self.rel(target_file)}", + ) + return + ids = self.anchors.get(target_file) + if ids is None: + self.fail( + path, + lineno, + f"fragment '#{fragment}' points at an unreadable file " + f"{self.rel(target_file)}", + ) + return + if fragment not in ids: + self.fail( + path, + lineno, + f"fragment '#{fragment}' has no matching anchor in " + f"{self.rel(target_file)}", + ) + + # -- SUMMARY.md, orphans -------------------------------------------------- + + def check_summary(self): + summary = self.book_root / "SUMMARY.md" + if not summary.exists(): + self.failures.append("SUMMARY.md: file is missing (checks 4 and 5 cannot run)") + return + summary_targets = [] # ordered list of resolved absolute Paths + for lineno, line in enumerate(summary.read_text(encoding="utf-8").split("\n"), 1): + for target, _col in iter_markdown_links(line): + if any(target.startswith(p) for p in SKIP_LINK_PREFIXES): + continue + path_part = target.partition("#")[0] + if not path_part.endswith(".md"): + continue + resolved = Path(os.path.normpath(summary.parent / path_part)) + summary_targets.append((resolved, lineno)) + + # README.md must be the first listed page. + readme = Path(os.path.normpath(self.book_root / "README.md")) + if not summary_targets: + self.failures.append("SUMMARY.md: lists no pages") + elif summary_targets[0][0] != readme: + self.fail( + summary, + summary_targets[0][1], + f"first listed page must be README.md, found " + f"{self.rel(summary_targets[0][0])}", + ) + + # Missing files and duplicate listings. + seen = {} + for resolved, lineno in summary_targets: + if not resolved.exists(): + self.fail(summary, lineno, f"lists a missing file: {self.rel(resolved)}") + if resolved in seen: + self.fail( + summary, + lineno, + f"lists {self.rel(resolved)} more than once " + f"(first at line {seen[resolved]})", + ) + else: + seen[resolved] = lineno + + # Every page (except SUMMARY.md itself) must be listed exactly once. + universe = {p for p in self.md_files if self.rel(p) != "SUMMARY.md"} + listed = set(seen) + for page in sorted(universe - listed): + self.fail(page, None, "page is not listed in SUMMARY.md (orphan)") + for resolved in sorted(listed - universe): + # Already reported as missing above if it does not exist; otherwise it + # is a listed file outside the discovered .md set (should not happen). + if resolved.exists(): + self.fail( + self.book_root / "SUMMARY.md", + None, + f"lists a file outside the book's page set: {self.rel(resolved)}", + ) + + # -- driver --------------------------------------------------------------- + + def run(self): + if not self.md_files: + self.failures.append(f"no markdown files found under {self.book_root}") + return 1 + self.scan_files() + self.validate_links() + self.check_summary() + if self.failures: + print( + f"check_links: {len(self.failures)} failure(s):", + file=sys.stderr, + ) + for failure in self.failures: + print(f" {failure}", file=sys.stderr) + return 1 + + print( + f"check_links: OK - {len(self.md_files)} files, {self.link_count} links, " + f"{self.anchor_count} anchors checked" + ) + return 0 + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.parse_args(argv) + + book_root = Path(__file__).resolve().parent.parent + if not book_root.is_dir(): + print(f"error: book root is not a directory: {book_root}", file=sys.stderr) + return 2 + return Checker(book_root).run() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/api/common/README.md b/api/common/README.md new file mode 100644 index 0000000..0a99cb2 --- /dev/null +++ b/api/common/README.md @@ -0,0 +1,14 @@ +# Vendored GovStack API common components + +These files are vendored from the draft incubation repository: + +- Source: <https://github.com/jeremi/govstack-api-common> + +| Local file | Version | Upstream file | Source state | SHA-256 | +|---|---|---|---|---| +| `govstack-openapi-common.yaml` | `0.1.0-draft` | `openapi/govstack-openapi-common.yaml` | Source commit `12b9b1acde9bfaa5a77e26380fa8f9e1f29bb28e` | `05d1bfc89c86a8d64005e343268326b3fb43cd044fe30f662fec0f9f573b0c73` | +| `govstack-asyncapi-common.yaml` | `0.1.0-draft` | `asyncapi/govstack-asyncapi-common.yaml` | Source commit `12b9b1acde9bfaa5a77e26380fa8f9e1f29bb28e` | `875c99f881d78c56b1b98e27198f904815c6e482c90c3c0fea574b1a792dd598` | + +The source commit and checksums pin the exact local artifacts. Update a vendored +file and this provenance record together; do not make component-source changes +only in this directory. diff --git a/api/common/govstack-asyncapi-common.yaml b/api/common/govstack-asyncapi-common.yaml new file mode 100644 index 0000000..792c4a1 --- /dev/null +++ b/api/common/govstack-asyncapi-common.yaml @@ -0,0 +1,132 @@ +asyncapi: 3.0.0 +info: + title: GovStack AsyncAPI Common Schemas + version: 0.1.0-draft + description: >- + Minimal reusable schemas for structured CloudEvents and transport-neutral + asynchronous errors in GovStack AsyncAPI specifications. + contact: + name: GovStack API Working Group + url: https://www.govstack.global/ +servers: {} +channels: {} +operations: {} +x-govstack-components-version: 0.1.0-draft +components: + schemas: + CloudEventEnvelope: + type: object + description: >- + CloudEvents 1.0 structured JSON envelope. A BB composes this schema in + a local Message Object and specialises type and data for its event. + required: + - specversion + - id + - source + - type + properties: + specversion: + type: string + const: '1.0' + description: CloudEvents wire version for the adopted 1.0.x specification. + id: + type: string + minLength: 1 + description: Unique identifier of this event occurrence. + source: + type: string + format: uri-reference + minLength: 1 + description: Stable logical URI reference for the publishing BB surface. + type: + type: string + pattern: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.[a-z][a-zA-Z0-9]*\.[a-z][a-zA-Z0-9]*$' + description: Reverse-DNS semantic event type without an API version segment. + time: + type: string + format: date-time + description: RFC 3339 time at which the event occurred. + datacontenttype: + type: string + const: application/json + description: Media type of the structured event data. + subject: + type: string + description: Optional opaque identifier of the resource concerned by the event. + traceparent: + type: string + pattern: '^00-(?!0{32})[\da-f]{32}-(?!0{16})[\da-f]{16}-0[01]$' + description: W3C Trace Context extension attribute for the distributed trace. + tracestate: + type: string + maxLength: 512 + description: Optional W3C Trace Context vendor state containing no personal data. + correlationid: + type: string + description: Optional workflow correlation identifier. + causationid: + type: string + description: Optional identifier of the message that caused this event. + idempotencykey: + type: string + maxLength: 255 + description: >- + Optional CloudEvents extension attribute for de-duplicating a + command that triggers non-idempotent processing. + data: + description: Optional event data whose requiredness and shape are specialised locally. + GovStackAsyncError: + type: object + description: Transport-neutral asynchronous command rejection or processing failure. + required: + - type + - title + - code + - traceId + - timestamp + properties: + type: + type: string + format: uri + description: Stable absolute problem-type URI. + title: + type: string + description: Short human-readable summary of the error type. + detail: + type: string + description: Human-readable explanation specific to this failure. + code: + type: string + pattern: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.(?:[a-z][a-zA-Z0-9]*|[0-9]+)$' + description: Stable namespaced machine-readable error identifier. + traceId: + type: string + pattern: '^(?!0{32}$)[\da-f]{32}$' + description: W3C trace-id correlating the failure with the originating work. + timestamp: + type: string + format: date-time + description: RFC 3339 time at which the failure occurred. + errors: + type: array + description: Optional field-level errors for invalid command data. + items: + $ref: '#/components/schemas/AsyncFieldError' + AsyncFieldError: + type: object + description: Field-level validation failure in asynchronous message data. + required: + - pointer + - code + - message + properties: + pointer: + type: string + description: JSON Pointer identifying the invalid message field. + code: + type: string + pattern: '^global\.govstack\.[a-z][a-z0-9-]{1,30}\.(?:[a-z][a-zA-Z0-9]*|[0-9]+)$' + description: Stable namespaced code for the field failure. + message: + type: string + description: Human-readable explanation of the field failure. diff --git a/api/common/govstack-openapi-common.yaml b/api/common/govstack-openapi-common.yaml new file mode 100644 index 0000000..f89b516 --- /dev/null +++ b/api/common/govstack-openapi-common.yaml @@ -0,0 +1,105 @@ +openapi: 3.1.0 +info: + title: GovStack OpenAPI Common Schemas + version: 0.1.0-draft + description: >- + Minimal cross-BB schemas for HTTP problem details, field validation, and + cursor pagination. Each BB owns its operations, security, parameters, + headers, responses, and examples. + contact: + name: GovStack API Working Group + url: https://www.govstack.global/ +paths: {} +x-govstack-components-version: 0.1.0-draft +components: + schemas: + Problem: + type: object + description: >- + RFC 9457 problem details with one stable machine identifier in type and + a trace identifier for correlation. Error text contains no personal + data or system-internal detail. + required: + - type + - title + - status + - traceId + properties: + type: + type: string + format: uri + pattern: '^https://govstack\.global/problems/[a-z][a-z0-9-]{1,30}/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$' + description: >- + Stable GovStack problem-type URI and machine identifier in the form + https://govstack.global/problems/{bb-code}/{problem-slug}, where the + problem slug uses kebab-case. + title: + type: string + minLength: 1 + description: Short human-readable summary of the problem type. + status: + type: integer + minimum: 400 + maximum: 599 + description: HTTP status code for this occurrence. + detail: + type: string + description: Human-readable explanation specific to this occurrence. + instance: + type: string + format: uri-reference + description: URI reference identifying this problem occurrence. + traceId: + type: string + pattern: '^(?!0{32}$)[\da-f]{32}$' + description: The 32-hex-digit trace-id component of the effective request traceparent. + ValidationProblem: + description: Problem details for a request containing one or more invalid fields. + allOf: + - $ref: '#/components/schemas/Problem' + - type: object + description: Field-level validation details. + required: + - errors + properties: + errors: + type: array + minItems: 1 + description: One entry for each request field that failed validation. + items: + $ref: '#/components/schemas/FieldError' + FieldError: + type: object + description: Human-readable failure associated with one request field. + required: + - pointer + - message + properties: + pointer: + type: string + format: json-pointer + description: JSON Pointer identifying the invalid request field. + message: + type: string + minLength: 1 + description: Human-readable explanation of the field failure. + PageInfo: + type: object + description: Cursor metadata for a bounded collection response. + required: + - nextCursor + properties: + nextCursor: + type: + - string + - 'null' + minLength: 1 + description: Opaque non-empty cursor for the next page, or null on the final page. + total: + type: integer + minimum: 0 + description: >- + Optional total number of matching resources when inexpensive to + compute. Each BB use must document whether the value is exact or + estimated and whether it describes the current collection or the + snapshot used to produce the first page. diff --git a/api/index.yaml b/api/index.yaml new file mode 100644 index 0000000..7c2ea5d --- /dev/null +++ b/api/index.yaml @@ -0,0 +1,3 @@ +version: 1 +noApi: true +reason: This repository is a template and does not define a Building Block API surface. diff --git a/api/swagger.json b/api/swagger.json deleted file mode 100644 index e69de29..0000000 diff --git a/api/swagger.yaml b/api/swagger.yaml deleted file mode 100644 index e69de29..0000000 diff --git a/spec/.gitbook/assets/Govstack_scheduler_BB_APIs.json b/spec/.gitbook/assets/Govstack_scheduler_BB_APIs.json deleted file mode 100644 index 30a508a..0000000 --- a/spec/.gitbook/assets/Govstack_scheduler_BB_APIs.json +++ /dev/null @@ -1,3755 +0,0 @@ -{ - "openapi" : "3.0.1", - "info" : { - "title" : "Govstack Scheduler API", - "description" : "Interfaces to services rendered by Scheduler building block", - "termsOfService" : "TBD", - "contact" : { - "email" : "psramkumar2@gmail.com" - }, - "license" : { - "name" : "Apache 2.0", - "url" : "http://www.apache.org/licenses/LICENSE-2.0.html" - }, - "version" : "1.0.0" - }, - "externalDocs" : { - "description" : "Find out more about scheduler", - "url" : "https://www.govstack.global/" - }, - "tags" : [ { - "name" : "event", - "description" : "event management API" - }, { - "name" : "entity", - "description" : "entity management API" - }, { - "name" : "alertSchedule", - "description" : "alertSchedule management API" - }, { - "name" : "resource", - "description" : "resource management API" - }, { - "name" : "subscribers", - "description" : "subscriber management API" - }, { - "name" : "message", - "description" : "message management API" - }, { - "name" : "log", - "description" : "log management API" - }, { - "name" : "affiliation", - "description" : "affiliation management API" - }, { - "name" : "appointment", - "description" : "appointment management API" - } ], - "paths" : { - "/event/new" : { - "post" : { - "tags" : [ "event" ], - "summary" : "create a new event, in eventList with given eventDetails, returns new event id or failure error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/eventNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created event", - "example" : "eventId:12345" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/event/modifications" : { - "put" : { - "tags" : [ "event" ], - "summary" : "changes values of specific details (as given by eventDetails) of given event (selected by eventFilter) in eventList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "eventId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "12345" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/eventModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, event updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/event" : { - "delete" : { - "tags" : [ "event" ], - "summary" : "delete a predefined event in eventList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "eventId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "12345" - } - } ], - "responses" : { - "200" : { - "description" : "success, event cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/event/listDetails" : { - "get" : { - "tags" : [ "event" ], - "summary" : "get list of events filetered by criteria in eventFilter and return info specified by eventDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/eventGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,eventList", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/eventList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/appointment/new" : { - "post" : { - "tags" : [ "appointment" ], - "summary" : "create a new appointment, in appointmentList with given appointmentDetails, returns new appointment id or failure error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/appointmentNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created appointment", - "example" : "[eventId:12345,appointmentId:1]" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/appointment/modifications" : { - "put" : { - "tags" : [ "appointment" ], - "summary" : "changes values of specific details (as given by appointmentDetails) of given appointment (selected by appointmentFilter)in appointmentList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "appointmentId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/appointmentModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, appointment updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/appointment" : { - "delete" : { - "tags" : [ "appointment" ], - "summary" : "delete a predefined appointment in appointmentList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "appointmentId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - } ], - "responses" : { - "200" : { - "description" : "success, appointment cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/appointment/listDetails" : { - "get" : { - "tags" : [ "appointment" ], - "summary" : "get list of appointments filetered by criteria in appointmentFilter and return info specified by appointmentDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/appointmentGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,appointmentList", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/appointmentList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/entity/new" : { - "post" : { - "tags" : [ "entity" ], - "summary" : "create a new entity, in entityList with given entityDetails, returns new entity id or entity error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/entityNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created entity", - "example" : "entityName:xyz hospital,entityId:67890" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/entity/modifications" : { - "put" : { - "tags" : [ "entity" ], - "summary" : "changes values of specific details (as given by entityDetails) of given entity (selected by entityFilter)in entityList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "entityId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "67890" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/entityModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, entity updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/entity" : { - "delete" : { - "tags" : [ "entity" ], - "summary" : "delete a predefined entity in entityList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "entityId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "67890" - } - } ], - "responses" : { - "200" : { - "description" : "success, entity cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/entity/listDetails" : { - "get" : { - "tags" : [ "entity" ], - "summary" : "get list of entities filetered by criteria in entityFilter and return info specified by entityDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/entityGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,entity list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/entityList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/resource/new" : { - "post" : { - "tags" : [ "resource" ], - "summary" : "create a new resource, in resourceList with given resourceDetails, returns new resource id or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/resourceNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created resource", - "example" : "resourceName:xyz hospital,resourceId:54321" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/resource/modifications" : { - "put" : { - "tags" : [ "resource" ], - "summary" : "changes values of specific details (as given by resourceDetails) of given resource (selected by resourceFilter)in resourceList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "resourceId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "54321" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/resourceModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, resource updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/resource" : { - "delete" : { - "tags" : [ "resource" ], - "summary" : "delete a predefined resource in resourceList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "resourceId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "54321" - } - } ], - "responses" : { - "200" : { - "description" : "success, resource cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/resource/listDetails" : { - "get" : { - "tags" : [ "resource" ], - "summary" : "get list of entities filetered by criteria in resourceFilter and return info specified by resourceDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/resourceGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,resource list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/resourceList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/resource/availability" : { - "get" : { - "tags" : [ "resource" ], - "summary" : "get details of resources matching criteria given by freeResourceFilter, that are free(unallocated) in a given date range and entity and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/resourceGetAvailabilityQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,resource list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/freeResourceList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/subscriber/new" : { - "post" : { - "tags" : [ "subscriber" ], - "summary" : "create a new subscriber, in subscriberList with given subscriberDetails, returns new subscriber id or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/subscriberNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created subscriber", - "example" : "subscriberName:xyz hospital,subscriberId:12345" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/subscriber/modifications" : { - "put" : { - "tags" : [ "subscriber" ], - "summary" : "changes values of specific details (as given by subscriberDetails) of given subscriber (selected by subscriberFilter)in subscriberList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "subscriberId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "12345" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/subscriberModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, subscriber updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/subscriber" : { - "delete" : { - "tags" : [ "subscriber" ], - "summary" : "delete a predefined subscriber in subscriberList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "subscriberId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "12345" - } - } ], - "responses" : { - "200" : { - "description" : "success, subscriber cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/subscriber/listDetails" : { - "get" : { - "tags" : [ "subscriber" ], - "summary" : "get list of entities filetered by criteria in subscriberFilter and return info specified by subscriberDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/subscriberGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,message list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/subscriberList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/message/new" : { - "post" : { - "tags" : [ "message" ], - "summary" : "create a new message, in messageList with given messageDetails, returns new message id or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/messageNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created message", - "example" : "messageName:xyz hospital,messageId:1" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/message/modifications" : { - "put" : { - "tags" : [ "message" ], - "summary" : "changes values of specific details (as given by messageDetails) of given message (selected by messageFilter)in messageList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "messageId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/messageModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, message updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/message" : { - "delete" : { - "tags" : [ "message" ], - "summary" : "delete a predefined message in messageList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "messageId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - } ], - "responses" : { - "200" : { - "description" : "success, message cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/message/listDetails" : { - "get" : { - "tags" : [ "message" ], - "summary" : "get list of entities filetered by criteria in messageFilter and return info specified by messageDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/messageGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,message list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/messageList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/affiliation/new" : { - "post" : { - "tags" : [ "affiliation" ], - "summary" : "create a new affiliation, in affiliationList with given affiliationDetails, returns new affiliation id or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/affiliationNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created affiliation", - "example" : "affiliationName:xyz hospital,affiliationId:1" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/affiliation/modifications" : { - "put" : { - "tags" : [ "affiliation" ], - "summary" : "changes values of specific details (as given by affiliationDetails) of given affiliation (selected by affiliationFilter)in affiliationList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "affiliationId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/affiliationModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, affiliation updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/affiliation" : { - "delete" : { - "tags" : [ "affiliation" ], - "summary" : "delete a predefined affiliation in affiliationList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "affiliationId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - } ], - "responses" : { - "200" : { - "description" : "success, affiliation cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/affiliation/listDetails" : { - "get" : { - "tags" : [ "affiliation" ], - "summary" : "get list of entities filetered by criteria in affiliationFilter and return info specified by affiliationDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/affiliationGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,affiliation list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/affiliationList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/alertSchedule/new" : { - "post" : { - "tags" : [ "alertSchedule" ], - "summary" : "create a new alertSchedule, in alertScheduleList with given alertScheduleDetails, returns new alertSchedule id or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/alertScheduleNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created alertSchedule", - "example" : "alertScheduleName:xyz hospital,alertScheduleId:1" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/alertSchedule/modifications" : { - "put" : { - "tags" : [ "alertSchedule" ], - "summary" : "changes values of specific details (as given by alertScheduleDetails) of given alertSchedule (selected by alertScheduleFilter)in alertScheduleList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string" - }, - "example" : "Organizer" - }, { - "name" : "alertScheduleId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/alertScheduleModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, alertSchedule updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/alertSchedule" : { - "delete" : { - "tags" : [ "alertSchedule" ], - "summary" : "delete a predefined alertSchedule in alertScheduleList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "alertScheduleId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - } ], - "responses" : { - "200" : { - "description" : "success, alertSchedule cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/alertSchedule/listDetails" : { - "get" : { - "tags" : [ "alertSchedule" ], - "summary" : "get list of entities filetered by criteria in alertScheduleFilter and return info specified by alertScheduleDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/alertScheduleGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,alertSchedule list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/alertScheduleList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/log/new" : { - "post" : { - "tags" : [ "log" ], - "summary" : "create a new log, in logList with given logDetails, returns new logId or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/logNewQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success", - "content" : { - "application/json" : { - "schema" : { - "type" : "string", - "description" : "ID of created log", - "example" : "logName:xyz hospital,logId:1" - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/log/modifications" : { - "put" : { - "tags" : [ "log" ], - "summary" : "changes values of specific details (as given by logDetails) of given log (selected by logFilter)in logList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "logId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/logModifyQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success, log updated" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/log" : { - "delete" : { - "tags" : [ "log" ], - "summary" : "delete a predefined log in logList and returns success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "logId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "1" - } - } ], - "responses" : { - "200" : { - "description" : "success, log cancelled" - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - }, - "/log/listDetails" : { - "get" : { - "tags" : [ "log" ], - "summary" : "get list of entities filetered by criteria in logFilter and return info specified by logDetailsRequired with success or error code", - "parameters" : [ { - "name" : "requestorId", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "healthngoadmin1" - } - }, { - "name" : "requestToken", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "type" : "string", - "example" : "Organizer" - } - }, { - "name" : "query", - "in" : "query", - "required" : true, - "style" : "form", - "explode" : true, - "schema" : { - "$ref" : "#/components/schemas/logGetDetailsQuery" - } - } ], - "responses" : { - "200" : { - "description" : "success,log list", - "content" : { - "json" : { - "schema" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/logList" - } - } - } - } - }, - "400" : { - "description" : "Invalid request" - }, - "403" : { - "description" : "No permission" - }, - "404" : { - "description" : "one or more specified inputs not found" - }, - "default" : { - "description" : "Unexpected error" - } - } - } - } - }, - "components" : { - "schemas" : { - "entityList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "details" : { - "$ref" : "#/components/schemas/entityDetails" - } - } - } - }, - "entityDetails" : { - "type" : "object", - "properties" : { - "category" : { - "type" : "string", - "example" : "hospital" - }, - "name" : { - "type" : "string", - "example" : "abc" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "info@xyz.com" - }, - "website" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - } - } - }, - "entityFilter" : { - "type" : "object", - "properties" : { - "entityId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[67890,12121]" - }, - "category" : { - "type" : "string", - "example" : "hospital" - }, - "name" : { - "type" : "string", - "example" : "abc" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "info@xyz.com" - }, - "website" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - } - } - }, - "entityDetailsRequired" : { - "type" : "object", - "properties" : { - "entityId" : { - "type" : "boolean", - "example" : false - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "name" : { - "type" : "boolean", - "example" : true - }, - "phone" : { - "type" : "boolean", - "example" : true - }, - "email" : { - "type" : "boolean", - "example" : true - }, - "website" : { - "type" : "boolean", - "example" : true - } - } - }, - "appointmentCreationDetails" : { - "type" : "object", - "properties" : { - "exclusive" : { - "type" : "boolean", - "example" : "true" - }, - "eventIds" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[1]" - }, - "participantType" : { - "type" : "string", - "example" : "subscriber" - }, - "participantId" : { - "type" : "string", - "example" : "12345" - }, - "participantEntityId" : { - "type" : "string", - "example" : "67890" - } - } - }, - "appointmentList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "appointmentId" : { - "type" : "string", - "example" : "1" - }, - "details" : { - "$ref" : "#/components/schemas/appointmentDetails" - } - } - } - }, - "appointmentDetails" : { - "type" : "object", - "properties" : { - "exclusive" : { - "type" : "boolean", - "example" : "true" - }, - "eventId" : { - "type" : "string", - "example" : "1" - }, - "participantType" : { - "type" : "string", - "example" : "subscriber" - }, - "participantId" : { - "type" : "string", - "example" : "12345" - }, - "statusId" : { - "type" : "string", - "example" : "confirmed" - }, - "participantEntityId" : { - "type" : "string", - "example" : "67890" - } - } - }, - "appointmentFilter" : { - "type" : "object", - "properties" : { - "exclusive" : { - "type" : "boolean", - "example" : "true" - }, - "appointmentId" : { - "type" : "string", - "example" : "1" - }, - "participantType" : { - "type" : "string", - "example" : "subscriber" - }, - "participantId" : { - "type" : "string", - "example" : "12345" - }, - "participantEntityId" : { - "type" : "string", - "example" : "67890" - }, - "status" : { - "type" : "string", - "example" : "confirmed" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T09:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T17:00:00" - } - } - }, - "appointmentDetailsRequired" : { - "type" : "object", - "properties" : { - "exclusive" : { - "type" : "boolean", - "example" : "true" - }, - "appointmentId" : { - "type" : "boolean", - "example" : true - }, - "eventDetails" : { - "type" : "boolean", - "example" : true - }, - "participantType" : { - "type" : "boolean", - "example" : true - }, - "participantId" : { - "type" : "boolean", - "example" : true - }, - "status" : { - "type" : "boolean", - "example" : true - }, - "participantEntityId" : { - "type" : "boolean", - "example" : true - } - } - }, - "resourceList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "resourceId" : { - "type" : "string", - "example" : "54321" - }, - "details" : { - "$ref" : "#/components/schemas/resourceDetails" - } - } - } - }, - "resourceDetails" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "example" : "psrk" - }, - "category" : { - "type" : "string", - "example" : "doctor" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "doctor1@xyz.com" - }, - "alertUrl" : { - "type" : "string", - "format" : "url", - "example" : "psrk@gmail.com" - }, - "alertPreference" : { - "type" : "string", - "example" : "phone" - }, - "statusPollUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.doctor1.com" - } - } - }, - "resourceFilter" : { - "type" : "object", - "properties" : { - "resourceId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[54321,31313]" - }, - "name" : { - "type" : "string", - "example" : "psrk" - }, - "category" : { - "type" : "string", - "example" : "doctor" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "abc@gmail.com" - }, - "alertUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.doctor1.com" - }, - "alertPreference" : { - "type" : "string", - "example" : " phone" - }, - "statusPollUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.doctor1.com" - } - } - }, - "resourceDetailsRequired" : { - "type" : "object", - "properties" : { - "resourceId" : { - "type" : "boolean", - "example" : true - }, - "name" : { - "type" : "boolean", - "example" : true - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "phone" : { - "type" : "boolean", - "example" : true - }, - "email" : { - "type" : "boolean", - "example" : true - }, - "alertUrl" : { - "type" : "boolean", - "example" : true - }, - "alertPreference" : { - "type" : "boolean", - "example" : true - }, - "statusPollUrl" : { - "type" : "boolean", - "example" : true - } - } - }, - "subscriberList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "subscriberId" : { - "type" : "string", - "example" : "12345" - }, - "details" : { - "$ref" : "#/components/schemas/subscriberDetails" - } - } - } - }, - "subscriberDetails" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "example" : "abc" - }, - "category" : { - "type" : "string", - "example" : "patient" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "abc@gmail.com" - }, - "alertUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - }, - "alertPreference" : { - "type" : "string", - "example" : "phone" - }, - "statusPollUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - } - } - }, - "subscriberFilter" : { - "type" : "object", - "properties" : { - "subscriberId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[12345, 41414]" - }, - "category" : { - "type" : "string", - "example" : "patient" - }, - "name" : { - "type" : "string", - "example" : "abc" - }, - "phone" : { - "type" : "string", - "example" : "+911234567890" - }, - "email" : { - "type" : "string", - "example" : "abc@gmail.com" - }, - "alertUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - }, - "alertPreference" : { - "type" : "string", - "example" : " phone" - }, - "statusPollUrl" : { - "type" : "string", - "format" : "url", - "example" : "www.abc.com" - } - } - }, - "subscriberDetailsRequired" : { - "type" : "object", - "properties" : { - "subscriberId" : { - "type" : "boolean", - "example" : true - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "name" : { - "type" : "boolean", - "example" : true - }, - "phone" : { - "type" : "boolean", - "example" : true - }, - "email" : { - "type" : "boolean", - "example" : true - }, - "alertUrl" : { - "type" : "boolean", - "example" : true - }, - "alertPreference" : { - "type" : "boolean", - "example" : true - }, - "statusPollUrl" : { - "type" : "boolean", - "example" : true - } - } - }, - "eventCreationDetails" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "example" : "abc medical camp" - }, - "description" : { - "type" : "string", - "example" : "medical camp for senior citizens" - }, - "category" : { - "type" : "string", - "example" : "doctor_consultation" - }, - "hostEntityId" : { - "type" : "string", - "example" : "67890" - }, - "slots" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:30:00" - } - } - } - }, - "deadline" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:10:00" - }, - "subscriberLimit" : { - "type" : "string", - "example" : "1" - }, - "terms" : { - "type" : "string", - "example" : "non refundable" - }, - "status" : { - "type" : "string", - "example" : "open" - }, - "venue" : { - "$ref" : "#/components/schemas/venue" - } - } - }, - "eventList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "eventId" : { - "type" : "string", - "example" : "12345" - }, - "details" : { - "$ref" : "#/components/schemas/eventDetails" - } - } - } - }, - "eventDetails" : { - "type" : "object", - "properties" : { - "name" : { - "type" : "string", - "example" : "abc medical camp" - }, - "description" : { - "type" : "string", - "example" : "medical camp for senior citizens" - }, - "category" : { - "type" : "string", - "example" : "doctor_consultation" - }, - "hostEntityId" : { - "type" : "string", - "example" : "67890" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:30:00" - }, - "deadline" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:10:00" - }, - "subscriberLimit" : { - "type" : "string", - "example" : "1" - }, - "terms" : { - "type" : "string", - "example" : "non refundable" - }, - "status" : { - "type" : "string", - "example" : "open" - }, - "venue" : { - "$ref" : "#/components/schemas/venue" - } - } - }, - "eventFilter" : { - "type" : "object", - "properties" : { - "eventId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[12345,51515]" - }, - "description" : { - "type" : "string", - "example" : "medical camp for senior citizens" - }, - "name" : { - "type" : "string", - "example" : "doctor_consultation" - }, - "category" : { - "type" : "string", - "example" : "opd_physician_consultation" - }, - "hostEntityId" : { - "type" : "string", - "example" : "67890" - }, - "subscriberLimit" : { - "type" : "string", - "example" : "1" - }, - "terms" : { - "type" : "string", - "example" : "non refundable" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T09:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T17:00:00" - }, - "venue" : { - "$ref" : "#/components/schemas/venue" - } - } - }, - "eventDetailsRequired" : { - "type" : "object", - "properties" : { - "eventId" : { - "type" : "boolean", - "example" : true - }, - "description" : { - "type" : "boolean", - "example" : true - }, - "name" : { - "type" : "boolean", - "example" : true - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "hostEntityId" : { - "type" : "boolean", - "example" : true - }, - "period" : { - "type" : "boolean", - "example" : true - }, - "venue" : { - "type" : "boolean", - "example" : true - }, - "deadline" : { - "type" : "boolean", - "example" : true - }, - "subscriberLimit" : { - "type" : "boolean", - "example" : true - }, - "terms" : { - "type" : "boolean", - "example" : true - }, - "status" : { - "type" : "boolean", - "example" : true - } - } - }, - "affiliationList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "affiliationId" : { - "type" : "string", - "example" : "1" - }, - "details" : { - "$ref" : "#/components/schemas/affiliationDetails" - } - } - } - }, - "affiliationDetails" : { - "type" : "object", - "properties" : { - "resourceId" : { - "type" : "string", - "example" : "12345" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "resourceCategory" : { - "type" : "string", - "example" : "physician" - }, - "workDaysHours" : { - "$ref" : "#/components/schemas/daysHours" - } - } - }, - "daysHours" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "dayOfWeek" : { - "type" : "string", - "example" : "[monday" - }, - "startTime" : { - "type" : "string", - "example" : "09:00:00" - }, - "endTime" : { - "type" : "string", - "example" : "17:00:00" - } - } - } - }, - "affiliationFilter" : { - "type" : "object", - "properties" : { - "affiliationId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[1,2]" - }, - "resourceId" : { - "type" : "string", - "example" : "12345" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "category" : { - "type" : "string", - "example" : "physician" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T09:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T17:00:00" - } - } - }, - "affiliationDetailsRequired" : { - "type" : "object", - "properties" : { - "affiliationId" : { - "type" : "boolean", - "example" : true - }, - "resourceId" : { - "type" : "boolean", - "example" : true - }, - "entityId" : { - "type" : "boolean", - "example" : true - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "workDaysHours" : { - "type" : "boolean", - "example" : true - } - } - }, - "alertScheduleList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "alertScheduleId" : { - "type" : "string", - "example" : "1" - }, - "details" : { - "$ref" : "#/components/schemas/alertScheduleDetails" - } - } - } - }, - "alertScheduleDetails" : { - "type" : "object", - "properties" : { - "eventId" : { - "type" : "string", - "example" : "12345" - }, - "targetCategory" : { - "type" : "string", - "example" : "subscriber" - }, - "messageId" : { - "type" : "string", - "example" : "1" - }, - "alertDatetime" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T09:00:00" - } - } - }, - "alertScheduleFilter" : { - "type" : "object", - "properties" : { - "alertScheduleId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[1,2]" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "targetCategory" : { - "type" : "string", - "example" : "subscriber" - }, - "messageId" : { - "type" : "string", - "example" : "1" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T09:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T13:30:00" - } - } - }, - "alertScheduleDetailsRequired" : { - "type" : "object", - "properties" : { - "alertScheduleId" : { - "type" : "boolean", - "example" : true - }, - "entityId" : { - "type" : "boolean", - "example" : true - }, - "messageId" : { - "type" : "boolean", - "example" : true - }, - "alertDatetime" : { - "type" : "boolean", - "example" : true - } - } - }, - "messageList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "messageId" : { - "type" : "string", - "example" : "1" - }, - "details" : { - "$ref" : "#/components/schemas/messageDetails" - } - } - } - }, - "messageDetails" : { - "type" : "object", - "properties" : { - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "category" : { - "type" : "string", - "example" : "reminder" - }, - "messageBody" : { - "type" : "string", - "example" : "you have doctor consultation today" - } - } - }, - "messageFilter" : { - "type" : "object", - "properties" : { - "messageId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[1,2]" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "category" : { - "type" : "string", - "example" : "reminder" - }, - "messageBody" : { - "type" : "string", - "example" : "you have doctor consultation today" - } - } - }, - "messageDetailsRequired" : { - "type" : "object", - "properties" : { - "messageId" : { - "type" : "boolean", - "example" : true - }, - "entityId" : { - "type" : "boolean", - "example" : true - }, - "category" : { - "type" : "boolean", - "example" : true - }, - "messageBody" : { - "type" : "boolean", - "example" : true - } - } - }, - "logList" : { - "type" : "array", - "items" : { - "type" : "object", - "properties" : { - "logId" : { - "type" : "string", - "example" : "1" - }, - "details" : { - "$ref" : "#/components/schemas/logDetails" - } - } - } - }, - "logDetails" : { - "type" : "object", - "properties" : { - "loggerRole" : { - "type" : "string", - "example" : "resource" - }, - "loggerId" : { - "type" : "string", - "example" : "1" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "logCategory" : { - "type" : "string", - "example" : "attendance" - }, - "datetime" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:00:00" - }, - "logData" : { - "type" : "string", - "example" : "eventId:12345,subscriberId:1,token:a2s3x2fer,status:attended" - } - } - }, - "logFilter" : { - "type" : "object", - "properties" : { - "logId" : { - "type" : "array", - "items" : { - "type" : "string" - }, - "example" : "[1,2]" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "category" : { - "type" : "string", - "example" : "attendance" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-15T11:30:00" - } - } - }, - "logDetailsRequired" : { - "type" : "object", - "properties" : { - "logId" : { - "type" : "boolean", - "example" : true - }, - "loggerCategory" : { - "type" : "boolean", - "example" : true - }, - "loggerId" : { - "type" : "boolean", - "example" : true - }, - "entityId" : { - "type" : "boolean", - "example" : true - }, - "logCategory" : { - "type" : "boolean", - "example" : true - }, - "datetime" : { - "type" : "boolean", - "example" : true - }, - "logData" : { - "type" : "boolean", - "example" : true - } - } - }, - "freeResourceList" : { - "type" : "array", - "items" : { - "$ref" : "#/components/schemas/freeResourceDetails" - } - }, - "freeResourceDetails" : { - "type" : "object", - "properties" : { - "resourceId" : { - "type" : "string", - "example" : "1" - }, - "resourceName" : { - "type" : "string", - "example" : "abc" - }, - "freeSlots" : { - "type" : "array", - "items" : { - "type" : "string", - "example" : "{[2018-02-15T11:00:00to2018-02-15T11:09:00:00],[2018-02-22T11:00:00to2018-02-22T11:17:00:00]}" - } - } - } - }, - "freeResourceFilter" : { - "type" : "object", - "properties" : { - "category" : { - "type" : "string", - "example" : "doctor" - }, - "entityId" : { - "type" : "string", - "example" : "67890" - }, - "from" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-14T09:00:00" - }, - "to" : { - "type" : "string", - "format" : "date-time", - "example" : "2018-02-18T13:30:00" - }, - "resourceId" : { - "type" : "string", - "example" : "1" - } - } - }, - "venue" : { - "type" : "object", - "properties" : { - "building" : { - "type" : "string", - "example" : "xyz" - }, - "street" : { - "type" : "string", - "example" : "7th main" - }, - "area" : { - "type" : "string", - "example" : "wilson garden" - }, - "city" : { - "type" : "string", - "example" : "bangalore" - }, - "state" : { - "type" : "string", - "example" : "karnataka" - }, - "country" : { - "type" : "string", - "example" : "india" - }, - "lat" : { - "type" : "string", - "example" : "0.001" - }, - "long" : { - "type" : "string", - "example" : "0.002" - } - } - }, - "eventNewQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/eventCreationDetails" - } - } - }, - "eventModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/eventDetails" - } - } - }, - "eventGetDetailsQuery" : { - "type" : "object", - "properties" : { - "eventFilter" : { - "$ref" : "#/components/schemas/eventFilter" - }, - "eventDetailsRequired" : { - "$ref" : "#/components/schemas/eventDetailsRequired" - } - } - }, - "entityNewQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/entityDetails" - } - } - }, - "entityModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/entityDetails" - } - } - }, - "entityGetDetailsQuery" : { - "type" : "object", - "properties" : { - "entityFilter" : { - "$ref" : "#/components/schemas/entityFilter" - }, - "entityDetailsRequired" : { - "$ref" : "#/components/schemas/entityDetailsRequired" - } - } - }, - "resourceNewQuery" : { - "type" : "object", - "properties" : { - "resourceDetails" : { - "$ref" : "#/components/schemas/resourceDetails" - } - } - }, - "resourceModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/resourceDetails" - } - } - }, - "resourceGetDetailsQuery" : { - "type" : "object", - "properties" : { - "resourceFilter" : { - "$ref" : "#/components/schemas/resourceFilter" - }, - "resourceDetailsRequired" : { - "$ref" : "#/components/schemas/resourceDetailsRequired" - } - } - }, - "resourceGetAvailabilityQuery" : { - "type" : "object", - "properties" : { - "freeResourceFilter" : { - "$ref" : "#/components/schemas/freeResourceFilter" - } - } - }, - "subscriberNewQuery" : { - "type" : "object", - "properties" : { - "subscriberDetails" : { - "$ref" : "#/components/schemas/subscriberDetails" - } - } - }, - "subscriberModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/subscriberDetails" - } - } - }, - "subscriberGetDetailsQuery" : { - "type" : "object", - "properties" : { - "subscriberFilter" : { - "$ref" : "#/components/schemas/subscriberFilter" - }, - "subscriberDetailsRequired" : { - "$ref" : "#/components/schemas/subscriberDetailsRequired" - } - } - }, - "messageNewQuery" : { - "type" : "object", - "properties" : { - "messageDetails" : { - "$ref" : "#/components/schemas/messageDetails" - } - } - }, - "messageModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/messageDetails" - } - } - }, - "messageGetDetailsQuery" : { - "type" : "object", - "properties" : { - "messageFilter" : { - "$ref" : "#/components/schemas/messageFilter" - }, - "messageDetailsRequired" : { - "$ref" : "#/components/schemas/messageDetailsRequired" - } - } - }, - "affiliationNewQuery" : { - "type" : "object", - "properties" : { - "affiliationDetails" : { - "$ref" : "#/components/schemas/affiliationDetails" - } - } - }, - "affiliationModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/affiliationDetails" - } - } - }, - "affiliationGetDetailsQuery" : { - "type" : "object", - "properties" : { - "affiliationFilter" : { - "$ref" : "#/components/schemas/affiliationFilter" - }, - "affiliationDetailsRequired" : { - "$ref" : "#/components/schemas/affiliationDetailsRequired" - } - } - }, - "alertScheduleNewQuery" : { - "type" : "object", - "properties" : { - "alertScheduleDetails" : { - "$ref" : "#/components/schemas/alertScheduleDetails" - } - } - }, - "alertScheduleModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/alertScheduleDetails" - } - } - }, - "alertScheduleGetDetailsQuery" : { - "type" : "object", - "properties" : { - "alertScheduleFilter" : { - "$ref" : "#/components/schemas/alertScheduleFilter" - }, - "alertScheduleDetailsRequired" : { - "$ref" : "#/components/schemas/alertScheduleDetailsRequired" - } - } - }, - "logNewQuery" : { - "type" : "object", - "properties" : { - "logDetails" : { - "$ref" : "#/components/schemas/logDetails" - } - } - }, - "logModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/logDetails" - } - } - }, - "logGetDetailsQuery" : { - "type" : "object", - "properties" : { - "logFilter" : { - "$ref" : "#/components/schemas/logFilter" - }, - "logDetailsRequired" : { - "$ref" : "#/components/schemas/logDetailsRequired" - } - } - }, - "appointmentNewQuery" : { - "type" : "object", - "properties" : { - "appointmentDetails" : { - "$ref" : "#/components/schemas/appointmentCreationDetails" - } - } - }, - "appointmentModifyQuery" : { - "type" : "object", - "properties" : { - "details" : { - "$ref" : "#/components/schemas/appointmentDetails" - } - } - }, - "appointmentGetDetailsQuery" : { - "type" : "object", - "properties" : { - "appointmentFilter" : { - "$ref" : "#/components/schemas/appointmentFilter" - }, - "appointmentDetailsRequired" : { - "$ref" : "#/components/schemas/appointmentDetailsRequired" - } - } - } - } - } -} diff --git a/spec/8-service-apis.md b/spec/8-service-apis.md index 7ce47c9..1c7c2c4 100644 --- a/spec/8-service-apis.md +++ b/spec/8-service-apis.md @@ -5,27 +5,15 @@ This section provides a reference for APIs that should be implemented by this Bu The [GovStack non-functional requirements document](https://govstack.gitbook.io/specification/architecture-and-nonfunctional-requirements/6-onboarding) provides additional information on how 'adaptors' may be used to translate an existing API to the patterns described here. This section also provides guidance on how candidate products are tested and how GovStack validates a product's API against the API specifications defined here. {% hint style="success" %} -All APIs will be defined using the OpenAPI (Swagger) standard. The API definitions will be hosted outside of this document. This section may provide a brief description of required APIs. +Keep the canonical interface inventory in [`api/index.yaml`](../api/index.yaml). Use OpenAPI for synchronous HTTP APIs, AsyncAPI for event-driven APIs, or a normative protocol-standard declaration where creating a synthetic OpenAPI document would be misleading. A Building Block with no API surface must declare that explicitly. -This section will primarily contain links to the GitHub repository for OpenAPI definition (yaml) files as well as to a website hosted by GovStack that provides a live API documentation portal. +When one or more API surfaces are declared, map each active interface requirement to its operations, messages, or non-API verification in `api/coverage.yaml`. Follow the [GovStack Cross-BB API Design Guide](../api-design-guide/README.md) for conventions and validation. Note that APIs should be grouped by functional area (from sections 4 and 6) where appropriate. -OpenAPI links to the GitHub repository can be made in an interactive way using the GitBook OpenAPI widget, linking to the GitHub repo version of the .yaml file, remembering to link to the “raw” url. An example from the Registries BB is shown below and can be replaced. +This section may link to rendered API documentation, but do not embed a second copy of a canonical contract in the GitBook assets. {% endhint %} ## 8.1 Administrative APIs -{% swagger src=".gitbook/assets/Govstack_scheduler_BB_APIs.json" path="/event/new" method="post" %} -[Govstack_scheduler_BB_APIs.json](.gitbook/assets/Govstack_scheduler_BB_APIs.json) -{% endswagger %} - -{% swagger src=".gitbook/assets/Govstack_scheduler_BB_APIs.json" path="/event/modifications" method="put" %} -[Govstack_scheduler_BB_APIs.json](.gitbook/assets/Govstack_scheduler_BB_APIs.json) -{% endswagger %} - -{% swagger src=".gitbook/assets/Govstack_scheduler_BB_APIs.json" path="/event" method="delete" %} -[Govstack_scheduler_BB_APIs.json](.gitbook/assets/Govstack_scheduler_BB_APIs.json) -{% endswagger %} - ## 8.2 User APIs