From e0c17f220dfe0942fd36d34dd3ff61644a361a14 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 5 Jul 2026 00:34:02 +1000 Subject: [PATCH 01/11] Planned and tasked --- .specify/feature.json | 2 +- CLAUDE.md | 2 +- .../checklists/requirements.md | 35 +++ .../data-model.md | 112 +++++++++ specs/006-api-lifecycle-management/plan.md | 171 +++++++++++++ .../quickstart.md | 46 ++++ .../006-api-lifecycle-management/research.md | 176 ++++++++++++++ specs/006-api-lifecycle-management/spec.md | 137 +++++++++++ specs/006-api-lifecycle-management/tasks.md | 224 ++++++++++++++++++ 9 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 specs/006-api-lifecycle-management/checklists/requirements.md create mode 100644 specs/006-api-lifecycle-management/data-model.md create mode 100644 specs/006-api-lifecycle-management/plan.md create mode 100644 specs/006-api-lifecycle-management/quickstart.md create mode 100644 specs/006-api-lifecycle-management/research.md create mode 100644 specs/006-api-lifecycle-management/spec.md create mode 100644 specs/006-api-lifecycle-management/tasks.md diff --git a/.specify/feature.json b/.specify/feature.json index c1975c5..16c2eb8 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/005-lab-5-mocking-testing" + "feature_directory": "specs/006-api-lifecycle-management" } diff --git a/CLAUDE.md b/CLAUDE.md index cdde312..ff4165e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,5 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan -at specs/005-lab-5-mocking-testing/plan.md +at specs/006-api-lifecycle-management/plan.md diff --git a/specs/006-api-lifecycle-management/checklists/requirements.md b/specs/006-api-lifecycle-management/checklists/requirements.md new file mode 100644 index 0000000..f49c466 --- /dev/null +++ b/specs/006-api-lifecycle-management/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Lab 6 - API Lifecycle Management + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-04 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Both clarification points raised during drafting (example API choice; retirement handling) were resolved interactively with the user before this checklist was generated — see spec.md's Clarifications section. +- All items pass on first validation pass. diff --git a/specs/006-api-lifecycle-management/data-model.md b/specs/006-api-lifecycle-management/data-model.md new file mode 100644 index 0000000..e93cd4d --- /dev/null +++ b/specs/006-api-lifecycle-management/data-model.md @@ -0,0 +1,112 @@ +# Data Model: Lab 6 — API Lifecycle Management + +No database or persisted application state is introduced. All "data" in this lab is Backstage +catalog entity metadata (YAML), read at request time by one new read-only frontend card. This +document describes the shape of that catalog metadata and the in-memory view the frontend +constructs from it — not schema for a datastore. + +## Entity: System (logical API grouping) + +New catalog kind used for the first time in this repository (already allowed by +`catalog.rules: allow: [..., System, ...]` in `app-config.yaml`, unused until now). + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: museum-api + description: > + Logical grouping of all major versions of the Museum API. +spec: + owner: group:default/museum-team +``` + +- **name**: The logical API's stable identity, independent of any version number. +- **owner**: Same owning team as its version entities (Lab 2's `museum-team`), consistent with + "visibility of other catalog entry types remains allow all" (System pages are open to everyone + regardless of any version's private/shared visibility, per the existing permission policy's + Rule 1 — non-API kinds are always visible). + +## Entity: API (one per major version) + +Existing kind, extended with two new pieces of metadata (an annotation and the `spec.system` +relation); `spec.lifecycle` is Backstage's existing native field, not a new one. + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: museum-api-v1 + description: > + Museum API — version 1. Covers special events, museum hours, and ticket purchasing. + tags: [rest, openapi, sample] + annotations: + example.com/visibility: private # unchanged from Lab 2 + apiportal.io/version: "1.0" # new: major.minor version identifier +spec: + type: openapi + lifecycle: production # or: development | testing | deprecated | retired + owner: group:default/museum-team + system: museum-api # new: relation to the System above + definition: + $text: https://raw.githubusercontent.com/DawMatt/backstage-apiportal-lab/main/labs/lab-01-base-backstage/apis/museum/openapi.yaml +``` + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: museum-api-v2 + description: > + Museum API — version 2. Introduces breaking changes to special-event identifiers and the + ticket QR-code endpoint; see the lab README's changelog callout. + tags: [rest, openapi, sample] + annotations: + example.com/visibility: private + apiportal.io/version: "2.0" +spec: + type: openapi + lifecycle: development # advanced through testing → production during the lab + owner: group:default/museum-team + system: museum-api + definition: + $text: https://raw.githubusercontent.com/DawMatt/backstage-apiportal-lab/main/labs/lab-06-api-lifecycle-management/apis/museum-v2/openapi.yaml +``` + +### Fields + +| Field | Source | Notes | +|---|---|---| +| `metadata.name` | New per version (`-v`) | Catalog-unique; the mechanism that makes "multiple versions" representable at all (FR-001). | +| `metadata.annotations['apiportal.io/version']` | New | `"."` string; sort key and "latest" computation input (research.md R2). Lab-defined convention — learners may rename or extend to full semver. | +| `spec.lifecycle` | Existing native field | Free-form string; convention values for this lab: `development`, `testing`, `production`, `deprecated`, `retired` (research.md R3). | +| `spec.system` | Existing native field, first used here | Relation to the shared `System` entity; the grouping mechanism (research.md R1). | +| `spec.owner` / `example.com/visibility` | Unchanged from Lab 2 | Inherited per-version, independent of every other version (FR-012). | + +## Derived (non-persisted) view: Version list entry + +Computed client-side by `ApiVersionsCard.tsx` for each sibling `API` entity sharing the viewed +entity's `spec.system` — not stored anywhere. + +| Field | Derivation | +|---|---| +| `name` | `metadata.name` | +| `version` | `metadata.annotations['apiportal.io/version']` | +| `lifecycle` | `spec.lifecycle` | +| `isLatest` | `true` for the sibling with the numerically highest `version` among those whose `lifecycle !== 'retired'`; if every sibling is `retired`, the highest overall is flagged instead (edge case from spec.md) | +| `isRetired` | `lifecycle === 'retired'` — controls default collapse behavior (research.md R4) | +| `entityRef` | Standard Backstage entity ref, used for the `EntityRefLink` to that version's own page | + +## State transitions (`spec.lifecycle`, per version, independent of sibling versions) + +```text +development → testing → production → deprecated → retired +``` + +- Each arrow is a one-line YAML edit to that version's own file (research.md R7); no enforcement + of ordering exists in Backstage itself — the lab documents the intended progression, it is not + machine-validated. A version may also skip states (e.g. `development` → `deprecated`) if a + learner chooses to; this is not blocked, consistent with Backstage's own lack of a lifecycle + state machine. +- `retired` is a terminal state: the entity is never deleted (FR-009); only its default visibility + within the Versions card changes (collapsed behind "Show retired versions"). diff --git a/specs/006-api-lifecycle-management/plan.md b/specs/006-api-lifecycle-management/plan.md new file mode 100644 index 0000000..effa498 --- /dev/null +++ b/specs/006-api-lifecycle-management/plan.md @@ -0,0 +1,171 @@ +# Implementation Plan: Lab 6 — API Lifecycle Management + +**Branch**: `006-api-lifecycle-management` | **Date**: 2026-07-04 | **Spec**: [spec.md](spec.md) + +**Input**: Feature specification from `specs/006-api-lifecycle-management/spec.md` + +## Summary + +Lab 6 builds on the running Backstage 1.51.0 instance from Labs 1–5. It demonstrates multiple +major versions of the same logical API coexisting in the catalog, a default browsing experience +that surfaces the latest version while keeping older ones reachable, independent per-version +lifecycle states, and a two-step deprecate-then-retire progression — using **only native +Backstage catalog relations/fields plus one new read-only frontend card**, with no new backend +module. Two `API` entities, `museum-api-v1` (Lab 1's unmodified Museum API, re-registered under a +versioned name) and `museum-api-v2` (a new spec with deliberate breaking changes), are grouped +under one new `System` entity (`system:default/museum-api`) via the native `spec.system` relation +(research.md R1) — superseding Lab 2's single `museum-api` catalog entry (research.md R6, an +explicitly-permitted "breaking change to the environment" per the constitution's Development +Workflow section). A new `apiportal.io/version` annotation drives a *computed* (not manually +flagged) "latest version" determination (research.md R2). Lifecycle state reuses Backstage's +existing free-form `spec.lifecycle` field — already rendered natively — extended with a +documented five-value convention (`development`/`testing`/`production`/`deprecated`/`retired`, +research.md R3). A new frontend module, `apiVersions` (`packages/app/src/modules/apiVersions/`), +follows the exact `EntityCardBlueprint` pattern already used by Lab 2's `apiVisibility` and Lab +3's `apiGrade`: it lists every sibling version of the API being viewed, flags the latest, and +collapses `retired` versions behind a "Show retired versions" toggle, closed by default +(research.md R4). Backstage's built-in full-text search/catalog table is deliberately left +unmodified — the Versions card is the taught "default" discovery surface, a documented design +boundary rather than a scaling gap (research.md R5). All lifecycle transitions, including +retirement, are one-line `spec.lifecycle` edits picked up via existing catalog polling — no new +mutation endpoint or UI action (research.md R7). No entity is ever deleted (FR-009). + +## Technical Context + +**Language/Version**: TypeScript / Node.js 20 LTS (Backstage 1.51.0, pinned from Lab 1) + +**Primary Dependencies**: None new. Reuses `@backstage/plugin-catalog-react/alpha`'s +`EntityCardBlueprint` (already a dependency, used by Lab 2's `apiVisibility` and Lab 3's +`apiGrade`) and the existing `catalogApiRef` (`useApi(catalogApiRef).getEntities()`) already used +elsewhere in the Backstage frontend. No new root or `packages/app` dependency is added. + +**Storage**: No database or generated config. All new state is committed catalog YAML (one +`System` entity, two `API` entities, one new OpenAPI spec file) plus a `catalog.locations` update +in `app-config.yaml` (data-model.md). No runtime-generated files, consistent with Labs 1–4 (Lab +5's mock-gateway is the only lab with a generated-artifact concern, and this lab has none). + +**Testing**: Manual browser verification per lab README (no automated test suite — consistent +with Labs 1–5's tutorial-lab testing approach; see quickstart.md for the exact verification +walkthrough). + +**Target Platform**: Local development machine — Windows 10/11 and macOS 12+ (same as Labs 1–5). + +**Project Type**: Tutorial lab — Markdown documentation + catalog YAML + one new OpenAPI spec +file + one new small TypeScript frontend module (an `EntityCardBlueprint` info card). + +**Performance Goals**: The Versions card's `catalogApi.getEntities()` call is filtered +server-side to the viewed entity's own `spec.system`, so it returns only that API's sibling +versions (typically single digits) regardless of total catalog size — O(1) relative to catalog +scale, not O(total APIs) (research.md R8). + +**Constraints**: Zero cost; cross-platform; no external network access beyond what Labs 1–5 +already require; supersedes Lab 2's single `museum-api` catalog entry (research.md R6) but edits +no other previously-committed spec/catalog file, and does not require Labs 1–5 to be redone +(FR-011). + +**Scale/Scope**: Lab demo: 1 new `System` entity, 2 new `API` entities (v1 re-registered +unmodified, v2 new with deliberate breaking changes), 1 new OpenAPI spec file, 1 new frontend +module (~1-2 files), 1 `app-config.yaml` `catalog.locations` edit (3 entries replacing 1), 0 new +dependencies, 0 new backend modules. Designed-for scale (config-only, per research.md R8): each +additional logical API at a 500+-API deployment is one more `System` + N more `API` YAML files, +with no code or architecture change and no growth in the Versions card's per-view query cost. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design — all gates hold.* + +| Principle | Gate | Status | +|-----------|------|--------| +| I. Feature-Focused Learning | Lab demonstrates concrete, named Backstage features: the `System` entity/`spec.system` relation for grouping, the native free-form `spec.lifecycle` field for per-version state, and the `EntityCardBlueprint` pattern for a computed cross-version summary view | ✅ Pass | +| II. Process-Oriented Documentation | README must explain WHY `System` (not a bespoke annotation) was chosen for grouping (research.md R1), WHY "latest" is computed rather than manually flagged (research.md R2), WHY Backstage's native search/catalog table is deliberately left unmodified rather than built into a custom collator (research.md R5 — a concrete worked example of a documented design boundary, worth calling out explicitly to learners), and WHY Lab 2's single `museum-api` entity is being superseded by two versioned entities (research.md R6). The new `ApiVersionsCard.tsx` and the new v2 OpenAPI spec are each likely to exceed the ~40–50 line inline threshold and MUST be committed under `labs/lab-06-api-lifecycle-management/code/` and `.../apis/museum-v2/` respectively, linked from the README, not inlined | ✅ Pass (with file-size rule flagged for tasks) | +| III. Cross-Platform Compatibility | Purely catalog YAML edits, one static OpenAPI file, and a pure-TypeScript React card — no native/platform-specific dependencies; `yarn start` continues to work identically on Windows and macOS; no new scripts or platform-specific commands are introduced | ✅ Pass | +| IV. Self-Contained Prerequisites | No new prerequisites beyond Labs 1–5's existing Node/Yarn/Backstage setup — no new dependency to install | ✅ Pass | +| V. Zero-Cost Operation | No new dependency, tool, or service of any kind, paid or otherwise | ✅ Pass | +| VI. Progressive Lab Structure | Requires Labs 1–5 completion; reuses Lab 1's Museum OpenAPI spec unmodified for v1; the one deliberate exception (superseding Lab 2's single `museum-api` catalog entry, research.md R6) is an explicitly-permitted "breaking change to the environment," documented in README and this plan rather than left implicit | ✅ Pass | +| VII. Modern & Purposeful API Examples | v1 reuses the already-compliant Museum API unmodified; v2 is a deliberately-breaking variant of the same well-designed API (not a new placeholder example), authored specifically to make the version difference concrete | ✅ Pass | +| VIII. Support Experimentation & Scale | README documents adaptable conventions: the `apiportal.io/version` annotation format, the five lifecycle-value convention, and that "System per logical API" is the pattern to replicate for a learner's own APIs. Verification tests outcomes (which version is flagged latest, which lifecycle chip shows, whether a retired version is collapsed), not implementation internals. **Scale**: the System+relation+filtered-query mechanism is config-only to extend (research.md R8) — no code/architecture change between the lab's 2-version demo and a 500+-API deployment. The one explicitly-flagged design boundary (native search/catalog table left unmodified, research.md R5) is documented as a deliberate, generalizing choice rather than an implicit gap | ✅ Pass | +| IX. Pragmatic Security for Learning Environments | N/A — no new credentials, secrets, or auth configuration are introduced by this lab (research.md R9); no Security Note section required | ✅ Pass (N/A, no new credential surface) | + +No violations. Complexity Tracking section omitted. + +## Project Structure + +### Documentation (this feature) + +```text +specs/006-api-lifecycle-management/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here) +``` + +No `contracts/` directory — this lab produces no external API contracts of its own. The new +catalog entity shapes (`System`, versioned `API`, annotation) are documented in data-model.md +instead. + +### Source Code (repository root) + +```text +labs/ +└── lab-06-api-lifecycle-management/ + ├── README.md # Lab documentation (overview, prereqs, steps, + │ # verification, troubleshooting, adaptable + │ # conventions per Constitution Principle VIII; + │ # no Security Note needed, research.md R9) + ├── catalog/ + │ ├── system.yaml # New: System entity (system:default/museum-api) + │ ├── apis/ + │ │ ├── museum-api-v1.yaml # New: v1 descriptor — supersedes Lab 2's single + │ │ │ # museum-api.yaml; references Lab 1's openapi.yaml + │ │ │ # unmodified (research.md R6) + │ │ └── museum-api-v2.yaml # New: v2 descriptor, references the new v2 spec + │ │ # below + │ └── apis/museum-v2/ + │ └── openapi.yaml # New: Museum API v2 with deliberate breaking + │ # changes vs. v1, documented as a changelog + │ # callout in the README (research.md R6) + └── code/ + └── packages/ + └── app/ + └── src/ + └── modules/ + └── apiVersions/ + ├── ApiVersionsCard.tsx # New: lists sibling versions, flags + │ # latest, collapses retired ones + │ # (research.md R2, R4) + └── index.ts # New: EntityCardBlueprint wrapper, + # same pattern as apiVisibility/apiGrade +``` + +Changes to the student's Backstage instance (guided by README, not committed as generated +output, but the module source itself IS committed since it is lab teaching content): + +```text +# In the student's Backstage instance (labs/lab-01-base-backstage/backstage/): +app-config.yaml # Modified: catalog.locations — remove Lab 2's + # single museum-api.yaml entry, add 3 new + # entries (system, museum-api-v1, museum-api-v2) +packages/app/ +└── src/ + ├── App.tsx # Modified: register apiVersionsModule in the + │ # features array (same pattern as Lab 2/3) + └── modules/ + └── apiVersions/ # New: copied from labs/lab-06-.../code/packages/ + ├── ApiVersionsCard.tsx # app/src/modules/apiVersions/ + └── index.ts +``` + +**Structure Decision**: Tutorial-documentation layout, consistent with Labs 1–5. All +student-facing content lives under `labs/lab-06-api-lifecycle-management/`; the new frontend +module is committed under its `code/` subdirectory (exceeds the ~40–50 line inline threshold per +Constitution Principle II) and linked from the README rather than embedded inline. The student's +Backstage instance (from Lab 1) is modified in place per the README instructions, same pattern as +Labs 2–5. Lab 1's original Museum OpenAPI file is reused unmodified for v1; the new v2 spec and +all new catalog descriptors live under this lab's own directory, not Lab 1's or Lab 2's. Speckit +artefacts live under `specs/006-api-lifecycle-management/` per convention. + +## Complexity Tracking + +No violations recorded — table omitted per template instructions. diff --git a/specs/006-api-lifecycle-management/quickstart.md b/specs/006-api-lifecycle-management/quickstart.md new file mode 100644 index 0000000..9174787 --- /dev/null +++ b/specs/006-api-lifecycle-management/quickstart.md @@ -0,0 +1,46 @@ +# Quickstart: Lab 6 — API Lifecycle Management + +This is the verification walkthrough the lab README will guide a learner through; it is the basis +for `tasks.md`'s acceptance-testing tasks, not new documentation prose in its own right. + +## Prerequisites + +- Labs 1–5 completed (running Backstage instance from Lab 1, teams/users/visibility from Lab 2, + quality tooling from Lab 3, auto-registration from Lab 4, mocking/testing from Lab 5). +- No new tools, accounts, or paid services required (Constitution Principle V). + +## Steps + +1. Add the new `System` entity (`museum-api`) and two versioned `API` entities (`museum-api-v1`, + `museum-api-v2`) under `labs/lab-06-api-lifecycle-management/catalog/`, and the new `museum-v2` + OpenAPI spec file under `labs/lab-06-api-lifecycle-management/apis/museum-v2/openapi.yaml`. +2. Update `app-config.yaml`'s `catalog.locations`: remove Lab 2's single `museum-api.yaml` entry, + add the three new entries (System, v1, v2). +3. Add the `apiVersions` frontend module (`packages/app/src/modules/apiVersions/`) and register it + in `packages/app/src/App.tsx`'s `features` array, following the same pattern as + `apiVisibilityModule`/`apiGradeModule`. +4. Restart Backstage (`yarn start`). Confirm the catalog now shows `museum-api-v1` and + `museum-api-v2` as separate entities, and a new `museum-api` System entity. +5. Open either version's page. Confirm the new "API Versions" card lists both versions, flags v2 as + Latest, and shows each one's own lifecycle chip (v1: production, v2: development). +6. Edit `museum-api-v2`'s catalog file: change `spec.lifecycle` from `development` to `testing`, + then to `production`. After each change, refresh the page and confirm the Versions card and the + entity's own About card reflect the new value, and that v1's entry is unaffected. +7. Edit `museum-api-v1`'s catalog file: change `spec.lifecycle` to `deprecated`. Confirm the + Deprecated label appears on v1's own page and in the Versions card on both v1 and v2's pages. +8. Edit `museum-api-v1`'s catalog file again: change `spec.lifecycle` to `retired`. Confirm v1 now + only appears in the Versions card behind the "Show retired versions" toggle (collapsed by + default), while remaining fully viewable via a direct link to its own page, clearly labeled + Retired. +9. Confirm no catalog entity was deleted at any point in steps 6–8 — `museum-api-v1` is still + present and inspectable in the catalog at the end. + +## Success Criteria Mapping + +| Quickstart Step | Spec Success Criterion | +|---|---| +| 4 | SC-001 | +| 5 | SC-002, SC-003 | +| 6 | SC-004 | +| 7–8 | SC-005 | +| 9 | SC-006 | diff --git a/specs/006-api-lifecycle-management/research.md b/specs/006-api-lifecycle-management/research.md new file mode 100644 index 0000000..65ccfb2 --- /dev/null +++ b/specs/006-api-lifecycle-management/research.md @@ -0,0 +1,176 @@ +# Research: Lab 6 — API Lifecycle Management + +## R1: Mechanism for grouping multiple versions of "the same" API + +**Decision**: Use Backstage's native `System` entity kind (already allowed by `catalog.rules` in +`app-config.yaml`, unused until now) as the logical-API grouping mechanism. Create one `System` +per logical API (e.g. `system:default/museum-api`), and set `spec.system` on every version's `API` +entity to point at it. + +**Rationale**: `spec.system` is a first-class, already-indexed Backstage relation — no new backend +code, plugin, or catalog processor is needed to establish "these entities are versions of the same +API." The System's own page also gets a "Has part" APIs list for free. This is the most +scale-friendly option: adding a new logical API at real scale (500+ APIs) is one more `System` YAML +file, not a code or architecture change (Constitution Principle VIII). + +**Alternatives considered**: +- A bespoke annotation (e.g. `apiportal.io/api-family: museum-api`) queried directly, skipping + `System` entirely. Rejected: reinvents a relation Backstage already models natively, and forfeits + the free System page/breadcrumb UI, for no benefit — the annotation approach still requires a + custom card to render anything. +- A `dependsOn`/`partOf` relation directly between the two `API` entities (no `System`). Rejected: + `System` is the semantically correct native entity for "one logical thing with multiple version + entities," and scales better as more versions are added (each new API version just adds one + `spec.system` line, rather than needing an ever-growing web of pairwise relations). + +## R2: Identifying and displaying the "latest" version + +**Decision**: Add an annotation, `apiportal.io/version: "."` (e.g. `"1.0"`, `"2.0"`), +to every version's `API` entity. "Latest" is *computed*, not manually flagged: the new frontend +card (R4) fetches all `API` entities sharing the current entity's `spec.system`, parses each one's +`apiportal.io/version` annotation, and highlights whichever non-retired version has the highest +value. + +**Rationale**: A manually-maintained `apiportal.io/latest: "true"` flag could drift (two entities +both marked latest, or none) — computing it from a single, unambiguous version number is simpler to +keep correct and is the more honest lesson (derive facts from data, don't hand-maintain a flag that +duplicates it). + +**Alternatives considered**: A manual `is-latest` boolean annotation — rejected for the drift risk +above. Semantic-version libraries for full semver comparison — rejected as overkill; major.minor +string comparison via numeric parsing is sufficient for this lab's two-version demo and documented +as adaptable for real semver strings. + +## R3: Representing lifecycle state (development/test/production/deprecated/retired) + +**Decision**: Reuse Backstage's native `spec.lifecycle` field on `API` entities — it is a free-form +string already rendered as a chip on the entity's built-in "About" card, with zero new code. Adopt +a documented convention of five values across this lab: `development`, `testing`, `production`, +`deprecated`, `retired` (the last two are additionally read by the new Versions card to decide +default visibility — see R5). + +**Rationale**: `spec.lifecycle` is not a closed enum in Backstage — any string is accepted and +displayed — so no schema change or new field is needed. This keeps the entire lifecycle mechanism a +one-line YAML edit per version, consistent with a GitOps mental model, and requires no new backend +code. + +**Alternatives considered**: A custom annotation (e.g. `apiportal.io/lifecycle-state`) instead of +the native field — rejected because it would duplicate a field Backstage already renders natively, +and per Lab 2's established precedent (Principle-driven requirement that displayed metadata must be +the same data used elsewhere, not a copy), reusing the real `spec.lifecycle` field is the correct +choice, not a parallel copy. + +## R4: Surfacing versions, latest, and retirement state on the API page + +**Decision**: Add one new frontend module, `apiVersions` (`packages/app/src/modules/apiVersions/`), +following the exact `EntityCardBlueprint` pattern already used by Lab 2's `apiVisibility` and Lab +3's `apiGrade` (`filter: 'kind:API'`, `type: 'info'`). Its `ApiVersionsCard.tsx`: +- Reads the current entity's `spec.system`. +- Calls `catalogApi.getEntities()` (via `useApi(catalogApiRef)`, already used elsewhere in the + Backstage frontend) filtered to `kind: API` entities with that same `spec.system`. +- Sorts by the `apiportal.io/version` annotation (R2), flags the highest non-retired one "Latest". +- Renders each sibling's version, lifecycle chip (reusing `spec.lifecycle`, R3), and a link + (`EntityRefLink`) to its own page. +- Versions whose `spec.lifecycle` is `retired` are collapsed by default behind a "Show retired + versions (N)" toggle (local component state, no persistence needed) — satisfying "excluded from + default view, reachable via one additional action" (FR-008) without hiding them from the catalog + itself. + +**Rationale**: This exactly mirrors two already-shipped, review-approved patterns in this +repository (`apiVisibility`, `apiGrade`) — same blueprint, same "info card reading `useEntity`/ +`catalogApi`" shape — so it needs no new integration pattern, only new lab-specific logic. + +**Alternatives considered**: A dedicated custom entity page tab instead of an info card. Rejected: +an info card is less code, matches the two nearest precedents in this repo, and the acceptance +scenarios only require "a clear way to navigate between versions," not a dedicated tab. + +## R5: Whether Backstage's own search/browse must be modified to hide retired versions + +**Decision**: Do not modify Backstage's built-in full-text search plugin or the default catalog +table. A raw catalog-wide text search may still technically return a retired version's entity +(clearly labeled `Retired` on its own page when opened). The Versions card (R4) — reachable from +any version's page or its System page — is the "default" discovery surface this lab teaches and +verifies against, and it is where latest-prominence and retired-collapsing (FR-003, FR-008) are +actually implemented. + +**Rationale**: Building a custom search collator/indexer or catalog-table filter to suppress +retired entities catalog-wide is a much larger change (new backend search extension) for a +questionable end: a "findable via deliberate raw search, but never promoted by the guided browsing +UI" split is a defensible design even at real scale — auditors/compliance search benefits from +finding *everything* registered, while day-to-day discovery benefits from the curated view. This is +recorded here as a deliberate design boundary, not an unaddressed simplification, per Constitution +Principle VIII's requirement to flag anything that doesn't generalize — this one does generalize +(the same card mechanism is exactly as effective at 500+ APIs as at 2). + +**Alternatives considered**: A custom search collator that excludes `retired` entities. Rejected as +disproportionate new backend surface area for a teaching lab, and it would actually work against a +legitimate real-world need (being able to find a retired API's historical record via search). + +## R6: Where v1/v2 catalog data and the new v2 spec live; superseding Lab 2's single entity + +**Decision**: Lab 6 supersedes Lab 2's single `museum-api` catalog entity with two versioned +entities, `museum-api-v1` and `museum-api-v2`, plus one new `System`, all newly authored under +`labs/lab-06-api-lifecycle-management/catalog/`. `app-config.yaml`'s `catalog.locations` list has +Lab 2's single `museum-api.yaml` location entry replaced with three new entries (System, v1, v2). +`museum-api-v1` points its `spec.definition.$text` at the exact same, unmodified Lab 1 OpenAPI file +(`labs/lab-01-base-backstage/apis/museum/openapi.yaml`) — no existing committed spec file is edited. +`museum-api-v2` is a new spec file, `labs/lab-06-api-lifecycle-management/apis/museum-v2/openapi.yaml`, +containing deliberate, documented breaking changes against v1 (e.g. renaming the `eventId` path +parameter to `id`, and removing/renaming the QR-code ticket endpoint), with `info.version: 2.0.0`. + +**Rationale**: The lab brief explicitly asks for "multiple major versions of the same API," which +requires the old single, unversioned entity name to be retired in favor of two clearly-versioned +ones — a real breaking/superseding change to the environment, which the constitution's Development +Workflow section explicitly permits ("Breaking changes to a lab's environment MUST be reflected in +all subsequent labs"). Reusing v1's OpenAPI file unchanged keeps the "difference between versions" +concrete and entirely attributable to the new v2 file, rather than muddying what changed. + +**Alternatives considered**: Leaving the original `museum-api` entity name in place as "v1" (no +rename). Rejected: it would leave one version unversioned in name while its sibling is `-v2`, +undermining the "these are parallel, equally-versioned entities" lesson and complicating the +Versions card's version-parsing logic for no benefit. + +## R7: Deprecation → retirement is a metadata edit, not a new action/button + +**Decision**: All lifecycle transitions (`development` → `testing` → `production`, and +`deprecated` → `retired`) are demonstrated as one-line edits to a version's committed +`spec.lifecycle` value, picked up through the catalog's existing location-polling mechanism — the +same GitOps-style flow already used for every other catalog metadata change in this repository. +No new "deprecate"/"retire" UI action or backend mutation endpoint is introduced. + +**Rationale**: Consistent with this repository's existing pattern (every catalog metadata change +so far — ownership, visibility, lifecycle — is a YAML edit, not an in-app mutation), and keeps the +lab's new surface area limited to one read-only frontend card (R4) plus catalog config, with no new +backend module at all. + +**Alternatives considered**: A button/action in the Versions card that calls a new backend endpoint +to mutate lifecycle state directly. Rejected: real production lifecycle changes to committed API +descriptors are typically PR-reviewed changes to source, not one-click in-app actions; introducing +a bespoke mutation endpoint would also require new backend permissions work out of proportion to +the lesson. + +## R8: Scale check (Constitution Principle VIII) + +**Decision**: The chosen mechanism (`System` entity + `spec.system` relation + `apiportal.io/version` +annotation + one read-only frontend card whose catalog query is filtered to the current entity's own +`spec.system`) has no scaling ceiling: adding another logical API's version family at real scale +(500+ APIs) is exactly one more `System` YAML file plus N more `API` YAML files — a config-only +change, no code or architecture change. The Versions card's `catalogApi.getEntities()` call is +filtered server-side to one `spec.system` value, so its cost is proportional to the number of +sibling versions of the one API being viewed (typically single digits), not to total catalog size. + +**Rationale**: Matches the pattern this constitution principle already codifies from Lab 4's +`autoApiRegistration` design — the demo only exercises two versions of one API, but the underlying +mechanism (relation + filtered query) is identical in shape and cost regardless of how many other +Systems/APIs exist elsewhere in the catalog. + +## R9: Security (Constitution Principle IX) + +**Decision**: No new credentials, secrets, or auth configuration are introduced by this lab. The +per-version owner/visibility model established in Lab 2 (`example.com/visibility` annotation + +`isEntityOwner` permission policy) is inherited unchanged and applies independently to each version +entity. No new "Security Note" section is required in the README (that obligation applies only to +labs introducing credential/auth configuration, first triggered by Lab 5). + +**Rationale**: This lab is purely catalog-relation and metadata work; it introduces no new attack +surface. diff --git a/specs/006-api-lifecycle-management/spec.md b/specs/006-api-lifecycle-management/spec.md new file mode 100644 index 0000000..94846d4 --- /dev/null +++ b/specs/006-api-lifecycle-management/spec.md @@ -0,0 +1,137 @@ +# Feature Specification: Lab 6 - API Lifecycle Management + +**Feature Branch**: `006-api-lifecycle-management` + +**Created**: 2026-07-04 + +**Status**: Draft + +**Input**: User description: "Create a new feature and specification using feature 6 from GOAL.md" — Lab 6 (API lifecycle management): Add multiple major versions of the same API into Backstage at the same time, to represent multiple versions running in production in parallel. Demonstrate how the multiple API versions would all be searchable and accessible, but the most recent version would be prominent and the only one offered to a new user looking for this type of API. Show how we would represent APIs progressing through the main lifecycle: development, test, production, including how these lifecycle states can vary across each API version. Show how we manage API retirement, including marking APIs for deprecation and then retiring them. + +## Clarifications + +### Session 2026-07-04 + +- Q: Which example API should demonstrate multiple major versions running in parallel? → A: Reuse the already-registered Museum API (Lab 1) and add a second, deliberately-breaking v2 spec authored for this lab — fully local, $0, and gives full control over what changes between versions so the lifecycle demo is clear. +- Q: How should a retired API version be handled once it reaches end-of-life? → A: It stays registered and viewable (e.g. via a direct link or an explicit "show all versions" view) but is excluded from default search/browse results, clearly labeled Retired — preserving a historical/audit trail rather than deleting it, consistent with how non-latest versions are already deprioritized rather than hidden. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Register and browse multiple major versions of the same API (Priority: P1) + +As a learner, I want to register two major versions of the same API (v1 and v2 of the Museum API) into the catalog at the same time, so I can see how Backstage represents multiple parallel-running versions of a single logical API rather than treating a new version as replacing the old one. + +**Why this priority**: This is the foundational capability the whole lab depends on — without both versions coexisting in the catalog, there is nothing to demonstrate prominence, lifecycle-per-version, or retirement against. + +**Independent Test**: Register both `museum-api-v1` and `museum-api-v2` (the latter authored for this lab with deliberate breaking changes) as separate catalog entities that are visibly related to each other (e.g. grouped under the same logical API), and confirm both appear in the catalog at the same time without one overwriting or hiding the other. + +**Acceptance Scenarios**: + +1. **Given** the Museum API is already registered from Lab 1, **When** a learner adds a second major version (v2) with intentionally breaking changes (e.g. renamed/removed fields), **Then** both v1 and v2 appear as distinct, independently viewable catalog entries that are visibly presented as versions of the same logical API (not two unrelated APIs). +2. **Given** both versions are registered, **When** a learner searches the catalog for the Museum API, **Then** the search surfaces both versions as related results rather than only one, or two entirely disconnected entities. +3. **Given** a learner opens either version's page, **When** they look for how to view the other version, **Then** a clear, direct way to navigate between the API's versions is present on the page. + +--- + +### User Story 2 - Discover the latest version by default, while older versions remain reachable (Priority: P1) + +As a new learner who doesn't yet know the Museum API has multiple versions, I want my default search/browse experience to surface the latest version prominently, so I don't accidentally start integrating against an old version — while still being able to explicitly find and use an older version if I need to. + +**Why this priority**: This directly demonstrates the lab's core teaching point (multiple versions coexist, but discovery defaults to latest) and is equally foundational to User Story 1; a catalog that only supports "coexistence" without a sensible default is only half the lesson. + +**Independent Test**: As a learner with no prior context, search or browse for "Museum API" and confirm the latest version (v2) is what's presented/opened by default, then confirm a v1-specific search or an explicit "other versions" control on v2's page still surfaces v1. + +**Acceptance Scenarios**: + +1. **Given** both v1 and v2 are registered, **When** a learner performs a general catalog search for the Museum API without specifying a version, **Then** the latest version (v2) is the prominent/default result. +2. **Given** a learner is viewing the latest version's page, **When** they look for older versions, **Then** an explicit "other versions" list or control is visible on the page, and following it reaches v1. +3. **Given** a learner explicitly searches for or navigates to v1 (e.g. by name), **When** the search resolves, **Then** v1 remains fully accessible and viewable, not hidden or blocked just because it is not the latest. + +--- + +### User Story 3 - Track lifecycle state independently per version (Priority: P2) + +As a learner, I want each version of the Museum API to carry its own lifecycle state (development, testing/experimental, or production), so I can see that v1 might be in production while v2 is still in development or test, and understand that lifecycle is a property of a version, not of the API as a whole. + +**Why this priority**: Builds directly on User Stories 1-2 by adding the "progressing through development/test/production" teaching point from the lab brief; it depends on both versions already existing and being independently viewable. + +**Independent Test**: Set v1's lifecycle to "production" and v2's lifecycle to "development" (or "experimental"), then view both versions' pages and confirm each displays its own, independent lifecycle state and that this is visible without opening the underlying entity YAML. + +**Acceptance Scenarios**: + +1. **Given** v1 is marked production and v2 is marked development, **When** a learner views each version's page, **Then** each page clearly displays that version's own lifecycle state, and the two differ from each other. +2. **Given** a learner advances v2's lifecycle from development to testing and later to production, **When** they refresh v2's page after each change, **Then** the displayed lifecycle state updates to match, without requiring changes to v1's entry. +3. **Given** the displayed lifecycle badge, **When** a learner checks its source, **Then** it reflects the same underlying catalog metadata used by any policy or tooling that reads lifecycle (not a separate, potentially out-of-sync display copy), consistent with how Lab 2 already requires metadata displays to be authoritative. + +--- + +### User Story 4 - Deprecate and then retire an API version (Priority: P2) + +As a learner, I want to mark an older API version as deprecated and later as retired, so I can see the full end-of-life progression an API version goes through, and understand how retired versions remain discoverable for historical/audit purposes without cluttering default discovery. + +**Why this priority**: Completes the lifecycle story established by User Story 3 by adding its terminal states; it is scoped separately because deprecation/retirement are distinct states from the earlier development/test/production progression and are more directly tied to the "old version being phased out" narrative. + +**Independent Test**: Mark v1 as deprecated and confirm it is visibly flagged as such wherever it appears (search results, its own page, and any "other versions" list on v2's page); then mark v1 as retired and confirm it disappears from default search/browse results while remaining reachable via a direct link or an explicit "all versions" view, clearly labeled Retired. + +**Acceptance Scenarios**: + +1. **Given** v1 is marked deprecated, **When** a learner sees it in search results, in the "other versions" list on v2's page, or on its own page, **Then** it is clearly labeled as deprecated in all of those locations. +2. **Given** v1 is later marked retired, **When** a learner performs a default catalog search or browse for the Museum API, **Then** v1 no longer appears among the default results. +3. **Given** v1 is retired, **When** a learner follows a direct link to it, or opens an explicit "show all versions" / full-catalog view, **Then** v1 is still viewable and clearly labeled Retired, preserving a historical record rather than being deleted. +4. **Given** the deprecation-then-retirement progression, **When** a learner reads the lab documentation, **Then** it explains this as a two-step process (deprecated first, retired later) rather than an API disappearing without warning. + +--- + +### Edge Cases + +- What happens when a learner searches using the exact name of a retired version? It MUST still resolve and open that version's page (clearly labeled Retired), consistent with "kept in catalog, excluded from default results" rather than being unreachable. +- What happens if every version of an API is deprecated or retired at the same time (no current production version)? The lab documentation MUST describe this as a valid, if unusual, end state and show what a learner sees when browsing to that API in that case (e.g. the most recently deprecated/retired version shown, clearly labeled, rather than an empty or broken page). +- What happens when a learner tries to determine "which version is production" versus "which version is latest" and they differ (e.g. v2 still in development while v1 remains the only production version)? The UI MUST make it possible to tell these two concepts apart rather than conflating "latest" with "production-ready." +- How does the relationship between v1 and v2 remain visible even though they are different catalog entities (Backstage catalog entities require unique names)? The mechanism used to group/relate them MUST be visible on both versions' pages, not only inferable from a shared name prefix in configuration files. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The system MUST support registering two or more major versions of the same logical API as separate, independently viewable catalog entities that exist in the catalog at the same time (neither replacing nor hiding the other). +- **FR-002**: The system MUST visibly present multiple versions of the same logical API as related to one another — on each version's own page and in relevant search/browse results — using a mechanism that does not depend on a learner recognizing a shared name prefix. +- **FR-003**: The system MUST make the most recent (latest) version prominent in default search/browse results for the API, while keeping older, non-retired versions fully reachable through an explicit action (e.g. an "other versions" list or a version-specific search). +- **FR-004**: The system MUST allow each version of an API to carry its own lifecycle state (at minimum: development, testing, production) independently of every other version of the same API, and display that state on the version's page using the same underlying metadata used by any policy/tooling that reads lifecycle (per the precedent set in Lab 2 for authoritative metadata display). +- **FR-005**: The system MUST allow a version's lifecycle state to be changed (e.g. development → testing → production) via catalog metadata, with the displayed state updating accordingly, without requiring changes to any other version's entry. +- **FR-006**: The system MUST support marking an API version as deprecated, with that status clearly and visibly flagged everywhere the version appears (its own page, general search results, and any "other versions" list). +- **FR-007**: The system MUST support marking a deprecated API version as retired as a distinct, later step — not the same action as deprecation — reflecting a two-stage end-of-life progression. +- **FR-008**: The system MUST exclude retired API versions from default catalog search/browse results, while keeping them reachable via a direct link or an explicit "show all versions"/full-catalog view, clearly labeled Retired. +- **FR-009**: The system MUST NOT delete or unregister a retired API version's catalog entity; retired versions remain a permanent, inspectable historical record within the catalog. +- **FR-010**: Lab documentation MUST demonstrate the versioning/lifecycle mechanism using two versions of the Museum API: v1 (already registered in Lab 1) unchanged, and a newly authored v2 containing deliberate breaking changes (e.g. renamed or removed fields/operations), so the difference between versions is concrete and easy to follow. +- **FR-011**: The lab MUST build upon the environment established by Labs 1-5 (base Backstage setup, users/roles/teams and visibility, API quality tooling, auto-registered API entities, mocking/testing) without requiring those labs to be redone. +- **FR-012**: This lab MUST NOT introduce a new visibility/permission mechanism; the ownership- and team-based visibility rules already established in Lab 2 continue to apply per version (e.g. each version keeps whatever owning-team/shared visibility the underlying API already had). +- **FR-013**: Lab documentation MUST identify which parts of the versioning/lifecycle/retirement mechanism (the specific grouping technique, lifecycle value names, retirement UI treatment) are conventions learners are expected to adapt to their own APIs and version-naming schemes, per Constitution Principle VIII. +- **FR-014**: The lab MUST run entirely on local, freely available Backstage catalog features and configuration, with $0 cost and no paid service dependency (Constitution Principle V). + +### Key Entities + +- **API Version Entity**: A single catalog entity representing one major version of a logical API (e.g. `museum-api-v1`, `museum-api-v2`). Carries its own OpenAPI spec, lifecycle state, and owner/visibility metadata, and is related to sibling versions of the same logical API. +- **Logical API Grouping**: The relationship that ties multiple API Version Entities together as versions of "the same API," surfaced on each version's page and in relevant search results, distinct from the catalog's per-entity uniqueness requirement. +- **Lifecycle State**: A per-version attribute (development, testing, production, deprecated, retired) recorded in catalog metadata and read by both the displayed UI badge and any policy/tooling that inspects it, so the two never diverge. +- **Retirement Record**: The persisted, non-deleted state of a retired API version — still present in the catalog, excluded from default discovery, reachable via direct link or an explicit all-versions view, and clearly labeled. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A learner can find both the Museum API's v1 and v2 registered in the catalog at the same time, and confirm from each version's page that it is presented as related to the other. +- **SC-002**: A learner performing a general, version-unaware search for the Museum API is shown the latest version (v2) as the prominent/default result, and can reach v1 within one additional click/action. +- **SC-003**: A learner can look at v1 and v2 side by side and correctly state each version's independent lifecycle state (e.g. "v1 is production, v2 is development") using only what's displayed in Backstage. +- **SC-004**: A learner can change a version's lifecycle state and see the displayed badge reflect the change without needing to inspect raw catalog YAML. +- **SC-005**: A learner can mark a version deprecated and observe the deprecated label everywhere that version appears, then mark it retired and observe it disappear from default search/browse results while remaining reachable via a direct link, within a single guided walkthrough. +- **SC-006**: Zero catalog entities are deleted as part of demonstrating retirement — the retired version's entity is still present and inspectable at the end of the lab. + +## Assumptions + +- "Multiple major versions running in parallel" is interpreted as multiple, separately-registered catalog entities for the same logical API (one per major version), each with its own OpenAPI spec — not multiple deployments of one identical spec. +- The Museum API is reused as the lab's worked example: v1 is the entity already registered in Lab 1, and v2 is a new spec authored for this lab containing deliberate breaking changes, so learners can see a concrete, understandable difference between versions. Learners adapting this lab to their own APIs are expected to substitute their own real version history. +- Backstage's catalog requires each entity to have a unique name, so per-version entities (e.g. `museum-api-v1`, `museum-api-v2`) are the mechanism for representing "multiple versions," related to each other via catalog-native relations/metadata (the specific technique — e.g. a shared `system`, a `partOf`/`dependsOn` relation, or a version annotation read by a custom grouping view — is a design decision made in the implementation plan, not this specification). +- "Lifecycle" states map onto Backstage's existing native `spec.lifecycle` field on API entities where possible (which already supports values like `experimental`, `production`, `deprecated`), extended with a documented convention for any state that field doesn't natively cover (e.g. a distinct "test" state or a "retired" state beyond `deprecated`); the exact mapping is an implementation-plan decision. +- "Retired" is treated as a further, distinct state beyond `deprecated` (not a synonym for it): a retired version's entity is never deleted, remains inspectable via direct link or an explicit all-versions/full-catalog view, and is excluded only from default search/browse prominence — consistent with how non-latest (but not deprecated/retired) versions are already deprioritized rather than hidden. +- This lab does not introduce a new visibility/permission model; the owning-team/shared visibility rules from Lab 2 continue to apply to each version entity independently, and API-quality tooling from Lab 3 continues to apply per version without change. +- No production traffic or real backend is required for v2's deliberately-breaking spec; as with other example APIs in this repository, its `servers` entry may be fictitious, since the lab's focus is catalog/lifecycle representation, not running a second live backend. diff --git a/specs/006-api-lifecycle-management/tasks.md b/specs/006-api-lifecycle-management/tasks.md new file mode 100644 index 0000000..d9ac90e --- /dev/null +++ b/specs/006-api-lifecycle-management/tasks.md @@ -0,0 +1,224 @@ +--- + +description: "Task list for Lab 6 — API Lifecycle Management" +--- + +# Tasks: Lab 6 — API Lifecycle Management + +**Input**: Design documents from `/specs/006-api-lifecycle-management/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md (all present; no `contracts/` — this lab produces no external API contracts) + +**Tests**: Not requested. Per plan.md's Testing section, verification is manual (browser, per quickstart.md's steps) — consistent with Labs 1–5. No automated test tasks are generated; quickstart validation is folded into each story's checkpoint and the final Polish phase. + +**Organization**: Tasks are grouped by user story (spec.md priorities P1/P1/P2/P2) to enable independent implementation and testing of each story. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (US1, US2, US3, US4) + +## Path Conventions + +This is a tutorial-lab project (not a generic web/mobile app). Three path roots are used: + +- **Backstage instance** (modified in place, from Lab 1): `labs/lab-01-base-backstage/backstage/` +- **Lab 6 teaching content** (new, committed alongside README): `labs/lab-06-api-lifecycle-management/` +- **Speckit artifacts**: `specs/006-api-lifecycle-management/` (this directory — no code changes here) + +**Note**: This lab supersedes Lab 2's single `museum-api` catalog entry with two versioned entities +(`museum-api-v1`, `museum-api-v2`) grouped under one new `System` entity — an explicitly-permitted +"breaking change to the environment" (research.md R6, plan.md Principle VI gate). + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Scaffold the directories every later task writes into. No new dependencies are needed (research.md — zero new frontend/root packages). + +- [ ] T001 Create the lab content directories `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/` and `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/` + +**Checkpoint**: Directories exist — ready for foundational work. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Establish the catalog data (System + both versioned API entities, including the new v2 spec) and the module-registration point every user story extends. No user story is independently testable until this phase is done. + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete. + +- [ ] T002 [P] Create the `System` entity `system:default/museum-api` in `labs/lab-06-api-lifecycle-management/catalog/system.yaml` (owner: `group:default/museum-team`, per data-model.md) +- [ ] T003 [P] Create `museum-api-v1.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/`: `metadata.name: museum-api-v1`, `annotations['apiportal.io/version']: "1.0"`, `example.com/visibility: private` (unchanged from Lab 2), `spec.lifecycle: production`, `spec.system: museum-api`, `spec.definition.$text` pointing at Lab 1's existing, unmodified `labs/lab-01-base-backstage/apis/museum/openapi.yaml` raw URL (research.md R6, data-model.md) +- [ ] T004 [P] Author `museum-v2/openapi.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/`: a deliberately-breaking variant of the Museum API — rename the `eventId` path parameter to `id` across the `/special-events/{eventId}` family of operations, and remove or rename the `/tickets/{ticketId}/qr` endpoint — with `info.version: 2.0.0` (research.md R6) +- [ ] T005 Create `museum-api-v2.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/`: `metadata.name: museum-api-v2`, `annotations['apiportal.io/version']: "2.0"`, `example.com/visibility: private`, `spec.lifecycle: development`, `spec.system: museum-api`, `spec.definition.$text` pointing at T004's new spec file's raw URL (depends on T004) +- [ ] T006 Update `labs/lab-01-base-backstage/backstage/app-config.yaml`'s `catalog.locations`: remove Lab 2's single `museum-api.yaml` location entry, add three new `type: url` entries for `system.yaml`, `museum-api-v1.yaml`, and `museum-api-v2.yaml` (research.md R6, depends on T002, T003, T005) +- [ ] T007 Scaffold the `apiVersions` frontend module as an `EntityCardBlueprint` (`filter: 'kind:API'`, `type: 'info'`) rendering a placeholder `ApiVersionsCard` that shows only the current entity's own name and `apiportal.io/version` annotation — no sibling lookup yet — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/{index.ts,ApiVersionsCard.tsx}` (research.md R4, same `EntityCardBlueprint` pattern as Lab 2's `apiVisibility`/Lab 3's `apiGrade`) +- [ ] T008 Copy the scaffolded module to `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/` and register `apiVersionsModule` in `labs/lab-01-base-backstage/backstage/packages/app/src/App.tsx`'s `features` array (depends on T007) + +**Checkpoint**: Both API versions and their System are registered and related; the card scaffold renders without error — ready for US1. + +--- + +## Phase 3: User Story 1 - Register and browse multiple major versions of the same API (Priority: P1) 🎯 MVP + +**Goal**: A learner sees `museum-api-v1` and `museum-api-v2` coexisting in the catalog, visibly presented as versions of the same logical API, with a clear way to navigate between them. + +**Independent Test**: Register both versions, confirm both appear in the catalog at the same time, and confirm each version's page shows a working link to the other. + +### Implementation for User Story 1 + +- [ ] T009 [US1] Implement sibling-lookup logic in `ApiVersionsCard.tsx`: read the current entity's `spec.system`, call `catalogApi.getEntities()` (via `useApi(catalogApiRef)`) filtered to `kind: API` entities sharing that `spec.system`, and render each sibling's name/version as a row linking to its own page via `EntityRefLink` — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (data-model.md, research.md R1/R4, depends on T007) +- [ ] T010 [US1] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T009, T008) +- [ ] T011 [US1] Manually verify (quickstart.md steps 4–5): the catalog shows `museum-api-v1`, `museum-api-v2`, and the `museum-api` System as separate entities existing at the same time; the System page lists both as "Has part" APIs; the Versions card on either version's page lists both, and following a link reaches the other (SC-001, depends on T006, T010) + +**Checkpoint**: User Story 1 fully functional and independently testable — this is the MVP. + +--- + +## Phase 4: User Story 2 - Discover the latest version by default, while older versions remain reachable (Priority: P1) + +**Goal**: The Versions card flags the latest version prominently while keeping older versions one click away. + +**Independent Test**: View the card on either version's page and confirm v2 is flagged Latest; follow the card's link from v2 to v1 and confirm v1 is fully accessible; confirm searching for v1 by name still resolves it directly. + +### Implementation for User Story 2 + +- [ ] T012 [US2] Add "Latest" computation to `ApiVersionsCard.tsx`: parse each sibling's `apiportal.io/version` annotation as a `[major, minor]` pair, compare numerically among siblings whose `spec.lifecycle !== 'retired'` (falling back to comparing all siblings if every one is retired, per spec.md's edge case), and render a "Latest" badge on the highest — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R2, depends on T009) +- [ ] T013 [US2] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T012, T010) +- [ ] T014 [US2] Manually verify (quickstart.md step 5): v2 is flagged Latest in the card on both v1 and v2's pages; v1 is reachable from v2's card within one click; navigating directly to `museum-api-v1` by name still resolves and opens its page (SC-002, depends on T013) + +**Checkpoint**: User Stories 1 AND 2 both work independently — versions coexist, are related, and the latest is prominent. + +--- + +## Phase 5: User Story 3 - Track lifecycle state independently per version (Priority: P2) + +**Goal**: Each version displays and can independently progress through its own lifecycle state, both on its own page and in the Versions card. + +**Independent Test**: Confirm v1 shows `production` and v2 shows `development` independently; advance v2 through `testing` then `production` and confirm only v2's displayed state changes. + +### Implementation for User Story 3 + +- [ ] T015 [US3] Add a lifecycle chip to each sibling row in `ApiVersionsCard.tsx`, reusing each entity's existing `spec.lifecycle` value (no new field) — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R3, depends on T012) +- [ ] T016 [US3] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T015, T013) +- [ ] T017 [US3] Manually verify (quickstart.md step 5): v1's own About card and its row in the Versions card both show `production`; v2's both show `development`; the two differ (SC-003, depends on T016) +- [ ] T018 [US3] Edit `museum-api-v2.yaml`'s `spec.lifecycle` from `development` to `testing`, then to `production` (two sequential edits); after each, refresh and confirm the displayed chip (own page and Versions card) updates to match, and that v1's entry is unaffected (SC-004, quickstart.md step 6, depends on T017) + +**Checkpoint**: All three user stories independently functional — versions coexist, latest is prominent, and lifecycle is tracked per version. + +--- + +## Phase 6: User Story 4 - Deprecate and then retire an API version (Priority: P2) + +**Goal**: An older version can be marked deprecated (visibly flagged everywhere) and later retired (collapsed from the default Versions view, but never deleted). + +**Independent Test**: Mark v1 deprecated and confirm the label appears everywhere it's shown; mark v1 retired and confirm it collapses behind a "Show retired versions" toggle while remaining reachable via direct link. + +### Implementation for User Story 4 + +- [ ] T019 [US4] Add retired-version collapsing to `ApiVersionsCard.tsx`: siblings with `spec.lifecycle === 'retired'` are grouped behind a closed-by-default "Show retired versions (N)" toggle (local component state), each still rendering its Deprecated/Retired lifecycle chip and working link when expanded — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R4/R5, depends on T015) +- [ ] T020 [US4] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T019, T016) +- [ ] T021 [US4] Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `deprecated`; confirm the Deprecated label appears on v1's own page and in the Versions card on both v1 and v2's pages (quickstart.md step 7, depends on T020) +- [ ] T022 [US4] Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `retired`; confirm v1 now only appears in the Versions card behind the "Show retired versions" toggle (collapsed by default) on both pages, while a direct link to `museum-api-v1` still opens it, clearly labeled Retired; confirm the entity itself is still present in the catalog — never deleted (SC-005, SC-006, quickstart.md steps 8–9, depends on T021) + +**Checkpoint**: All four user stories independently functional — this is the full lab. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Learner-facing documentation and final repo-wide consistency checks that span multiple stories. + +- [ ] T023 Write `labs/lab-06-api-lifecycle-management/README.md` Overview/Prerequisites/Steps sections (Constitution Principle II) covering: add the System/v1/v2 catalog entries and v2 spec, update `app-config.yaml` locations, scaffold/register the `apiVersions` module, start Backstage, and walk through latest-prominence, lifecycle-progression, and deprecate-then-retire verification — following quickstart.md steps 1–9, and linking to the committed `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml` and `code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` rather than inlining them (both exceed the ~40–50 line threshold) (depends on T011, T014, T018, T022) +- [ ] T024 Add the README's "Why a System entity, not a custom annotation" and "Why latest is computed, not hand-flagged" sections (research.md R1, R2) +- [ ] T025 [P] Add the README's "Why Backstage's native search/catalog table is left unmodified" section (research.md R5) — framed as a documented design boundary, not a gap, per Constitution Principle VIII +- [ ] T026 [P] Add the README's "Adaptable conventions vs. fixed mechanics" section (FR-013): the `apiportal.io/version` format, the five-value lifecycle convention, and the "one System per logical API" pattern are adaptable; the `System`/`spec.system` relation and the `EntityCardBlueprint` mechanism itself are fixed +- [ ] T027 Add the README's Verification and Troubleshooting sections (Lab Structure Standards) covering the manual checks already performed in T011/T014/T017/T018/T021/T022, plus common failure modes (forgetting to remove Lab 2's old `museum-api.yaml` catalog location, a `spec.system` typo silently producing an empty Versions card) +- [ ] T028 Update root `README.md` (Lab Series table entry, Getting Started tree, Repository Structure tree) to reference `labs/lab-06-api-lifecycle-management/` and `specs/006-api-lifecycle-management/`, per the Constitution's Lab Structure Standards — required for this lab to be considered complete +- [ ] T029 [P] Re-verify the Constitution Check table in plan.md against the final implementation (all 9 principles, with particular attention to Principle VI's "breaking change to the environment" justification and Principle VIII's scale claims) and correct plan.md if any gate's justification no longer matches what was built +- [ ] T030 Run the full quickstart.md validation sequence (steps 1–9) end-to-end against a clean `yarn start` of `labs/lab-01-base-backstage/backstage/` to confirm the lab works from a cold start, not just incrementally during development + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — start immediately +- **Foundational (Phase 2)**: Depends on Setup completion — BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Foundational completion — no dependency on US2/US3/US4 +- **User Story 2 (Phase 4)**: Depends on Foundational completion; extends the same file US1 built (T009), so in practice follows US1 sequentially even though it introduces no new *architectural* dependency +- **User Story 3 (Phase 5)**: Depends on Foundational completion; extends the file US1/US2 built (T012) — follows US2 sequentially for the same reason +- **User Story 4 (Phase 6)**: Depends on Foundational completion; extends the file US1/US2/US3 built (T015) — follows US3 sequentially +- **Polish (Phase 7)**: Depends on all four user stories being complete + +### User Story Dependencies + +- **User Story 1 (P1)**: Can start after Foundational — no dependency on other stories +- **User Story 2 (P1)**: Can start after Foundational, but shares `ApiVersionsCard.tsx` with US1 (same file, sequential edits) — implement after US1 for a working MVP checkpoint first +- **User Story 3 (P2)**: Can start after Foundational, but shares the same file with US1/US2 — implement after US2 +- **User Story 4 (P2)**: Can start after Foundational, but shares the same file with US1/US2/US3 — implement after US3 + +### Within Each User Story + +- Card logic change before the copy-into-Backstage-instance task +- Catalog YAML edit(s) (where applicable) before manual verification +- Manual verification task last in each phase (checkpoint gate) + +### Parallel Opportunities + +- T002, T003, T004 (Foundational: System entity, v1 descriptor, v2 spec file) can run in parallel — different files, no dependency between them +- T024/T025/T026 (Polish README sections) in parallel with each other +- T029 (Polish) in parallel with T025/T026 + +--- + +## Parallel Example: Foundational Catalog Data + +```bash +Task: "Create the System entity in labs/lab-06-api-lifecycle-management/catalog/system.yaml" +Task: "Create museum-api-v1.yaml in labs/lab-06-api-lifecycle-management/catalog/apis/" +Task: "Author museum-v2/openapi.yaml with deliberate breaking changes" +``` + +## Parallel Example: Polish README Sections + +```bash +Task: "Add 'Why native search is left unmodified' section" +Task: "Add 'Adaptable conventions vs. fixed mechanics' section" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +2. Complete Phase 2: Foundational (CRITICAL — blocks all stories) +3. Complete Phase 3: User Story 1 +4. **STOP and VALIDATE**: T011 confirms both versions coexist, are visibly related, and are mutually navigable +5. Demo: two major versions of the Museum API, registered and linked, with no version overwriting the other + +### Incremental Delivery + +1. Setup + Foundational → catalog data (System, v1, v2) and card scaffold ready +2. Add User Story 1 → validate independently (T011) → MVP demo +3. Add User Story 2 → validate independently (T014) → latest-prominence confirmed +4. Add User Story 3 → validate independently (T017/T018) → per-version lifecycle confirmed +5. Add User Story 4 → validate independently (T021/T022) → deprecate-then-retire confirmed +6. Polish → README (including the design-boundary worked example), root README update, cold-start validation, constitution re-check + +### Notes + +- US1 through US4 all extend one shared file (`ApiVersionsCard.tsx`) because each story adds a + layer to the same component — a deliberate incremental design (mirroring Lab 5's US1/US3 sharing + `apiMocking/index.tsx`), not an accidental cross-story coupling. Each story still has an + independently verifiable acceptance test even though the code lands in the same file. +- No test tasks are included (Tests: Not requested — see header). Verification is manual, per + quickstart.md, folded into each story's final task(s). +- Lab 2's single `museum-api` catalog entry is superseded (not edited in place) by + `museum-api-v1`/`museum-api-v2` — an explicitly-permitted "breaking change to the environment" + (research.md R6, plan.md Principle VI gate) — so no task edits Lab 2's own committed catalog file; + T006 only changes `app-config.yaml`'s location list. From 9ead967587cec7005f1d8f619d3b28f3f253583a Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 5 Jul 2026 13:09:58 +1000 Subject: [PATCH 02/11] Lifecycle lab initial implementation --- README.md | 21 +- .../lab-06-api-lifecycle-management/README.md | 266 ++++++++++ .../catalog/apis/museum-api-v1.yaml | 32 ++ .../catalog/apis/museum-api-v2.yaml | 31 ++ .../catalog/apis/museum-v2/openapi.yaml | 491 ++++++++++++++++++ .../catalog/system.yaml | 18 + .../modules/apiVersions/ApiVersionsCard.tsx | 189 +++++++ .../app/src/modules/apiVersions/index.ts | 18 + specs/006-api-lifecycle-management/tasks.md | 60 +-- 9 files changed, 1089 insertions(+), 37 deletions(-) create mode 100644 labs/lab-06-api-lifecycle-management/README.md create mode 100644 labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml create mode 100644 labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml create mode 100644 labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml create mode 100644 labs/lab-06-api-lifecycle-management/catalog/system.yaml create mode 100644 labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx create mode 100644 labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/index.ts diff --git a/README.md b/README.md index 56241b5..98ac090 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Each lab is self-contained, cross-platform (Windows and macOS), and costs nothin | [Lab 3](labs/lab-03-api-quality/) | API Quality | Add a shared Spectral ruleset, the api-grade quality plugin, and the Spectral linter plugin; API owners and a platform team see detailed quality/lint results, everyone else sees a summary grade | | [Lab 4](labs/lab-04-auto-registration/) | Auto Registration | Auto-discover and register APIs from a Git mono-repo via a custom `EntityProvider`; source owner, lifecycle, and visibility metadata from `x-*` fields in the spec itself | | [Lab 5](labs/lab-05-mocking-testing/) | Mocking & Testing | Dynamically mock any registered OpenAPI API via a single lazy-loading gateway process, or exercise a real sandbox; pre-filled, overridable non-production credentials | -| Lab 6 *(coming soon)* | API Lifecycle Management | Register multiple major versions of an API in parallel; track lifecycle state (development/test/production) and deprecation/retirement per version | +| [Lab 6](labs/lab-06-api-lifecycle-management/) | API Lifecycle Management | Register multiple major versions of an API in parallel via a native `System` relation; track lifecycle state (development/test/production) and deprecation/retirement per version | | Lab 7 *(coming soon)* | Other Documentation | Add the Thoughtworks Tech Radar plugin; register blips to plot your API landscape | Each lab builds on the one before it. Start with Lab 1 and work through them in order. See @@ -74,11 +74,16 @@ labs/ │ ├── README.md ← continue here after Lab 3 │ ├── autoApiRegistration.ts ← backend EntityProvider that scans and registers APIs │ └── apis/ ← auto-discovered specs, incl. the Scalar Galaxy vendor copy -└── lab-05-mocking-testing/ - ├── README.md ← continue here after Lab 4 +├── lab-05-mocking-testing/ +│ ├── README.md ← continue here after Lab 4 +│ └── code/ +│ ├── scripts/mock-gateway.mjs ← single lazy-loading Prism mock gateway +│ └── packages/app/src/modules/apiMocking/ ← apiDocsConfigRef override (mock + credentials) +└── lab-06-api-lifecycle-management/ + ├── README.md ← continue here after Lab 5 + ├── catalog/ ← System entity + museum-api-v1/v2, incl. the new v2 spec └── code/ - ├── scripts/mock-gateway.mjs ← single lazy-loading Prism mock gateway - └── packages/app/src/modules/apiMocking/ ← apiDocsConfigRef override (mock + credentials) + └── packages/app/src/modules/apiVersions/ ← versions/latest/lifecycle/retirement card ``` --- @@ -92,13 +97,15 @@ backstage-apiportal-lab/ │ ├── lab-02-users-roles/ │ ├── lab-03-api-quality/ │ ├── lab-04-auto-registration/ -│ └── lab-05-mocking-testing/ +│ ├── lab-05-mocking-testing/ +│ └── lab-06-api-lifecycle-management/ ├── specs/ ← SDD artifacts (spec, plan, tasks per lab) │ ├── 001-lab-1-base-backstage/ │ ├── 002-lab-2-users-roles/ │ ├── 003-lab-3-api-quality/ │ ├── 004-lab-4-auto-registration/ -│ └── 005-lab-5-mocking-testing/ +│ ├── 005-lab-5-mocking-testing/ +│ └── 006-api-lifecycle-management/ ├── .specify/ ← Speckit configuration and templates ├── GOAL.md ← high-level goals for the full lab series ├── CONTRIBUTING.md ← how to contribute new labs diff --git a/labs/lab-06-api-lifecycle-management/README.md b/labs/lab-06-api-lifecycle-management/README.md new file mode 100644 index 0000000..871aacd --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/README.md @@ -0,0 +1,266 @@ +# Lab 6 — API Lifecycle Management + +## Overview + +Every API registered so far in this series has had exactly one version. Real APIs rarely stay +that way — a new major version ships while the old one is still serving production traffic, and +each version moves through its own development → test → production → deprecated → retired +progression on its own schedule. This lab registers a second major version of the Museum API +alongside the original, and demonstrates how Backstage represents "these are versions of one +logical API," which version is prominent by default, and how a version is deprecated and +eventually retired without ever deleting its history. + +By the end of this lab you will have: + +- Two Museum API versions — `museum-api-v1` (Lab 1's original, unmodified) and `museum-api-v2` (a + new spec with deliberate breaking changes) — registered in the catalog **at the same time**, + grouped under one new `System` entity (`museum-api`) via Backstage's native `spec.system` + relation. +- A new frontend module (`packages/app/src/modules/apiVersions/`) that adds an "API Versions" + card to every API's page, listing its sibling versions, computing and flagging the latest one, + showing each version's own lifecycle chip, and collapsing retired versions behind a "Show + retired versions" toggle. +- A worked walkthrough of the full lifecycle: v2 starting in `development` and advancing through + `testing` to `production`, while v1 is marked `deprecated` and then `retired` — with v1's entity + never deleted at any point. + +**What you will learn:** + +- Why a `System` entity — not a bespoke annotation — is the right native mechanism for "these + entities are versions of one logical API" (see "Why a System entity" below) +- Why "latest version" is computed from a version annotation rather than a manually-maintained + flag, and what goes wrong with the manual-flag approach +- Why this lab deliberately leaves Backstage's built-in search and catalog table unmodified, + and where the "default" browsing experience actually lives instead +- Why `spec.lifecycle` — a field every API entity has already had since Lab 1 — is enough to + represent five lifecycle states, including one (`retired`) Backstage doesn't define itself + +--- + +## Prerequisites + +- **Labs 1–5 completed** — Backstage running locally with Museum, Streetlights, Train Travel, + Galaxy, and the precedence-demo API all registered, plus Lab 5's mocking/testing setup +- **Node.js 20+ (or 22/24) and Yarn** — same toolchain as Labs 1–5 +- No new external accounts, services, or OS-level prerequisites, and no new dependencies to + install — this lab only adds catalog YAML, one new OpenAPI spec, and one small frontend module + +--- + +## Step 1 — Add the System and Both API Versions to the Catalog + +Copy this lab's catalog files into your Backstage workspace's catalog location list. If you're +working directly in this repository (not a separate clone), the files are already at +[`catalog/system.yaml`](catalog/system.yaml), [`catalog/apis/museum-api-v1.yaml`](catalog/apis/museum-api-v1.yaml), +[`catalog/apis/museum-api-v2.yaml`](catalog/apis/museum-api-v2.yaml), and +[`catalog/apis/museum-v2/openapi.yaml`](catalog/apis/museum-v2/openapi.yaml). + +- **`system.yaml`** registers `system:default/museum-api` — the logical grouping every version + will point at via `spec.system`. +- **`museum-api-v1.yaml`** registers a *new* entity name, `museum-api-v1`, pointing its + `spec.definition.$text` at the exact same, unmodified OpenAPI file Lab 1 already registered + (`labs/lab-01-base-backstage/apis/museum/openapi.yaml`). It supersedes Lab 2's single + `museum-api` entity — see "Why supersede, not edit" below. +- **`museum-api-v2.yaml`** registers `museum-api-v2`, pointing at a **new** spec file + ([`catalog/apis/museum-v2/openapi.yaml`](catalog/apis/museum-v2/openapi.yaml)) with two + deliberate breaking changes: the `/special-events/{eventId}` path parameter is renamed to + `{id}`, and `GET /tickets/{ticketId}/qr` is renamed to `GET /tickets/{ticketId}/qr-code`. Both + changes are called out in the spec's own `info.description`. + +Both version entities carry: + +- `annotations['apiportal.io/version']` — a `"major.minor"` string (`"1.0"`, `"2.0"`) used to sort + versions and compute the latest one. +- `spec.system: museum-api` — the relation that groups them. +- `spec.lifecycle` — v1 starts at `production`, v2 starts at `development`. + +## Step 2 — Update `app-config.yaml`'s Catalog Locations + +In `app-config.yaml`'s `catalog.locations`, **remove** Lab 2's single "Museum REST API" location +entry and **add** three new entries — one for the System, one for each version: + +```yaml + # --- Lab 6: Museum API System (groups museum-api-v1/v2 as versions of one logical API) --- + - type: url + target: https://raw.githubusercontent.com//backstage-apiportal-lab/main/labs/lab-06-api-lifecycle-management/catalog/system.yaml + rules: + - allow: [System] + + # --- Lab 6: Museum API v1 --- + - type: url + target: https://raw.githubusercontent.com//backstage-apiportal-lab/main/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml + rules: + - allow: [API] + + # --- Lab 6: Museum API v2 (deliberate breaking changes vs. v1) --- + - type: url + target: https://raw.githubusercontent.com//backstage-apiportal-lab/main/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml + rules: + - allow: [API] +``` + +Replace `` with your own GitHub username/organization, matching the pattern already +used by every earlier lab's entries in this same file. + +## Step 3 — Add the API Versions Frontend Module + +Copy [`code/packages/app/src/modules/apiVersions/`](code/packages/app/src/modules/apiVersions/) +into your workspace's `packages/app/src/modules/apiVersions/`, then register it in +`packages/app/src/App.tsx` — the same pattern Lab 2's `apiVisibilityModule` and Lab 3's +`apiGradeModule` already use: + +```ts +import { apiVersionsModule } from './modules/apiVersions'; // ← add this import (Lab 6) + +export default createApp({ + features: [ + // ...earlier entries... + apiVersionsModule, // ← add this entry (Lab 6: version grouping, latest, lifecycle, retirement) + ], +}); +``` + +The module adds one `EntityCardBlueprint` info card (`ApiVersionsCard.tsx`) to every `kind:API` +entity's page — no new backend module, no new dependency. + +## Step 4 — Start Backstage and Confirm Both Versions Are Registered + +``` +cd labs/lab-01-base-backstage/backstage +yarn start +``` + +Open the catalog and confirm: + +- `museum-api-v1` and `museum-api-v2` both appear as separate entities. +- The `museum-api` System's page lists both as "Has part" APIs. +- Opening either version's page shows the new "API Versions" card, listing both versions with + working links between them, and `museum-api-v2` flagged **Latest**. + +## Step 5 — Confirm Independent Lifecycle State + +`museum-api-v1` starts at `production`, `museum-api-v2` starts at `development`. Confirm each +version's own "About" card and its row in the Versions card show its own state, and that the two +differ. + +## Step 6 — Advance v2's Lifecycle + +Edit `museum-api-v2.yaml`'s `spec.lifecycle`: change it from `development` to `testing`, then from +`testing` to `production`. After each edit, refresh either version's page — the Versions card and +v2's own About card should reflect the new value within Backstage's normal catalog refresh +interval, while `museum-api-v1` stays unaffected throughout. + +## Step 7 — Deprecate v1 + +Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `deprecated`. Confirm the Deprecated label appears +on v1's own page **and** in the Versions card on both v1 and v2's pages. + +## Step 8 — Retire v1 + +Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `retired`. Confirm: + +- v1 disappears from the Versions card's default (expanded) list on both pages. +- A "Show retired versions (1)" button appears; clicking it reveals v1, clearly labeled Retired. +- `museum-api-v1` is still fully viewable by navigating directly to its own catalog page — it was + never deleted, only deprioritized in the curated Versions view. + +## Step 9 — Confirm Nothing Was Deleted + +Open the full catalog list (or search for `museum-api-v1` by name directly). It is still there, +still labeled Retired, with its full history intact — retirement in this lab is a display +decision, not a data deletion. + +--- + +## Why a System Entity, Not a Custom Annotation + +Backstage already has a native entity kind for "these things belong to one logical group": +`System`. Setting `spec.system: museum-api` on every version gets you a real, already-indexed +relation — plus a free "Has part" list on the System's own page — with zero new backend code. A +bespoke annotation queried directly would still need a custom card to show anything, and would +forfeit that free System page for no benefit. See `research.md` R1 for the full comparison. + +## Why Latest Is Computed, Not Hand-Flagged + +It would be simpler to add an `apiportal.io/latest: "true"` annotation to whichever version is +newest. It would also be wrong the moment someone forgets to flip it when adding a third version — +two versions could both claim "latest," or none could. Instead, every version just carries its own +`apiportal.io/version` number, and the Versions card computes which one is highest (excluding +retired versions) every time it renders. There's no flag to get out of sync, because there's no +flag. + +## Why Backstage's Native Search Is Left Unmodified + +You might expect a retired version to be untraceable everywhere once it's retired — including +Backstage's own built-in full-text search. This lab deliberately does **not** touch that search +plugin or the default catalog table. A raw, catalog-wide search can still technically surface a +retired version (clearly labeled Retired once you open it) — that's the curated Versions card's +job, not the general-purpose search's. At real scale, this split is a feature, not a gap: an +auditor benefits from being able to find *everything* ever registered, while day-to-day discovery +benefits from a curated view that only shows what's current. See `research.md` R5. + +## Why Supersede Lab 2's Entry, Not Edit It + +Lab 2 registered a single `museum-api` entity. Introducing "multiple major versions of the same +API" properly requires versioned names (`museum-api-v1`, `museum-api-v2`) — there's no way to keep +one entity unversioned while its sibling is `-v2` and still teach "these are equally-versioned +parallel entities." This lab's `app-config.yaml` change therefore removes Lab 2's original catalog +location and replaces it with three new ones. This is an explicitly-permitted "breaking change to +the environment" under this repository's constitution (Development Workflow section) — if you're +adapting this pattern to your own APIs, plan for the same kind of one-time migration the first time +you introduce explicit versioning to an API that previously had none. + +--- + +## Adaptable Conventions vs. Fixed Mechanics + +Adaptable — change these freely for your own APIs: + +- The `apiportal.io/version` annotation format (`"major.minor"`) — substitute full semver, a date, + or whatever versioning scheme your organization already uses. +- The five-value lifecycle convention (`development`/`testing`/`production`/`deprecated`/ + `retired`) — `spec.lifecycle` accepts any string; use whatever states match your own process. +- "One `System` per logical API" — this is the pattern to replicate, not a fixed name; a real + catalog would have one `System` per API family, not just Museum's. + +Fixed — this is the mechanism itself, not a convention: + +- The `System`/`spec.system` relation as the grouping mechanism. +- The `EntityCardBlueprint` pattern used to build the Versions card. + +--- + +## Verification + +1. Both `museum-api-v1` and `museum-api-v2` appear in the catalog at the same time (Step 4). +2. The Versions card on either page lists both versions and flags v2 Latest (Step 4). +3. v1 and v2 show independent lifecycle chips that update independently as you edit them + (Steps 5–6). +4. Marking v1 deprecated shows the label everywhere v1 appears; marking it retired collapses it + behind "Show retired versions" while it remains reachable via direct link (Steps 7–8). +5. `museum-api-v1`'s entity is never deleted — confirm it's still present at the end (Step 9). + +## Troubleshooting + +- **Versions card renders but shows only one version**: confirm both `museum-api-v1.yaml` and + `museum-api-v2.yaml` set `spec.system: museum-api` (not `museum-api-v1`/`v2` — that's a typo that + silently produces an empty sibling list, since the card queries by the exact `spec.system` + string). +- **Old `museum-api` entity still shows up alongside the new versioned ones**: you likely forgot + to remove Lab 2's original catalog location entry from `app-config.yaml` in Step 2 — the old and + new entities can coexist harmlessly, but that defeats the "supersede, don't duplicate" point of + this lab. +- **Lifecycle edit doesn't show up immediately**: catalog processing runs on its normal refresh + interval; give it a few seconds and refresh the page, or trigger an immediate refresh via + Backstage's `POST /api/catalog/refresh` endpoint with the entity's ref. +- **v2's spec fails to resolve (`$text` 404)**: if you're working from a fork, update the + `$text` URL in `museum-api-v2.yaml` to point at your fork's raw URL, not this repository's. + +--- + +## Further Reading + +- [`research.md`](../../specs/006-api-lifecycle-management/research.md) — the full set of design + decisions and alternatives considered for this lab. +- [`data-model.md`](../../specs/006-api-lifecycle-management/data-model.md) — the exact entity + shapes and the lifecycle state-transition diagram. diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml new file mode 100644 index 0000000..0b98a74 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml @@ -0,0 +1,32 @@ +# Lab 6 — Museum API, version 1. +# +# Supersedes Lab 2's single `museum-api` catalog entry (labs/lab-02-users-roles/catalog/apis/ +# museum-api.yaml) — see labs/lab-06-api-lifecycle-management/README.md for why this lab +# introduces explicitly-versioned entity names instead of continuing to evolve one unversioned +# entity. The referenced OpenAPI file itself is untouched — this is a new catalog descriptor +# only, pointing at the same spec Lab 1 already registered. + +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: museum-api-v1 + description: > + A sample REST API for a fictional museum, demonstrating modern OpenAPI 3.1 design + practices. Covers special events, museum hours, and ticket purchasing. Version 1 — + see museum-api-v2 for the next major version and its breaking changes. + tags: + - rest + - openapi + - sample + annotations: + # Same visibility mechanism introduced in Lab 2 — unchanged, applies per version. + example.com/visibility: private + # Lab 6: major.minor version identifier, used to sort versions and compute "latest". + apiportal.io/version: "1.0" +spec: + type: openapi + lifecycle: production + owner: group:default/museum-team + system: museum-api + definition: + $text: https://raw.githubusercontent.com/DawMatt/backstage-apiportal-lab/main/labs/lab-01-base-backstage/apis/museum/openapi.yaml diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml new file mode 100644 index 0000000..0a56b85 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml @@ -0,0 +1,31 @@ +# Lab 6 — Museum API, version 2. +# +# A new major version of the Museum API, registered alongside (not replacing) v1. Its spec +# contains deliberate breaking changes vs. v1 — see catalog/apis/museum-v2/openapi.yaml's +# info.description for the exact changelog. Starts life at lifecycle: development, and is +# advanced through testing -> production during the lab walkthrough (README Step 6). + +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: museum-api-v2 + description: > + Museum API — version 2. Introduces breaking changes to special-event identifiers and + the ticket QR-code endpoint; see catalog/apis/museum-v2/openapi.yaml's changelog. + tags: + - rest + - openapi + - sample + annotations: + example.com/visibility: private + # Lab 6: major.minor version identifier, used to sort versions and compute "latest". + apiportal.io/version: "2.0" +spec: + type: openapi + lifecycle: development + owner: group:default/museum-team + system: museum-api + definition: + # NOTE: points at this feature branch, not main, because this spec file doesn't exist on + # main yet — same convention as Lab 4's precedence-demo entry. Update to main once merged. + $text: https://raw.githubusercontent.com/DawMatt/backstage-apiportal-lab/006-api-lifecycle-management/labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml new file mode 100644 index 0000000..a462e74 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml @@ -0,0 +1,491 @@ +openapi: 3.1.0 +info: + title: Museum API + description: > + An API for getting information about the museum, its special events, and purchasing + tickets. This is a sample API used in the Backstage API Portal Lab to demonstrate + OpenAPI catalog registration. + + + **Version 2 changes (Lab 6, deliberate breaking changes vs. v1):** + - The `/special-events/{eventId}` path parameter is renamed to `{id}`, and the matching + `SpecialEventResponse.eventId` field is renamed to `id`. + - `GET /tickets/{ticketId}/qr` is renamed to `GET /tickets/{ticketId}/qr-code`. + + See labs/lab-06-api-lifecycle-management/README.md for why these specific changes were + chosen and what a real consumer would need to do to migrate from v1. + version: 2.0.0 + contact: + name: Museum Support + email: support@example.museum + +servers: + - url: https://api.example.museum/v2 + description: Production server + +tags: + - name: Operations + description: Museum operating information + - name: Events + description: Special events at the museum + - name: Tickets + description: Ticket purchasing and management + +paths: + /museum-hours: + get: + summary: Get museum hours + description: > + Returns the opening hours for the museum. Hours may vary on public holidays — + check the special events endpoint for closures. + operationId: getMuseumHours + tags: + - Operations + parameters: + - name: startDate + in: query + description: The start date to retrieve hours from (inclusive). Defaults to today. + schema: + type: string + format: date + example: "2024-02-01" + - name: page + in: query + description: The page number to retrieve. + schema: + type: integer + default: 1 + minimum: 1 + - name: limit + in: query + description: The number of days per page. + schema: + type: integer + default: 7 + minimum: 1 + maximum: 30 + responses: + "200": + description: Museum opening hours returned successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMuseumHoursResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /special-events: + get: + summary: List special events + description: > + Returns a paginated list of special events at the museum. Events are sorted by + start date, most recent first. + operationId: listSpecialEvents + tags: + - Events + parameters: + - name: startDate + in: query + schema: + type: string + format: date + - name: endDate + in: query + schema: + type: string + format: date + - name: page + in: query + schema: + type: integer + default: 1 + minimum: 1 + - name: limit + in: query + schema: + type: integer + default: 10 + minimum: 1 + maximum: 30 + responses: + "200": + description: List of special events returned successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/ListSpecialEventsResponse" + + post: + summary: Create a special event + description: > + Creates a new special event. The event is created in draft status and must be + published before it appears in the public listing. + operationId: createSpecialEvent + tags: + - Events + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSpecialEventRequest" + responses: + "201": + description: Special event created successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SpecialEventResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /special-events/{id}: + parameters: + - name: id + in: path + required: true + description: > + Unique identifier for the special event. Renamed from `eventId` in v1 — see the + v2 changelog note in this file's `info.description`. + schema: + type: string + format: uuid + + get: + summary: Get a special event + operationId: getSpecialEvent + tags: + - Events + responses: + "200": + description: Special event details returned successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SpecialEventResponse" + "404": + $ref: "#/components/responses/NotFound" + + patch: + summary: Update a special event + operationId: updateSpecialEvent + tags: + - Events + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSpecialEventRequest" + responses: + "200": + description: Special event updated successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SpecialEventResponse" + "404": + $ref: "#/components/responses/NotFound" + + delete: + summary: Delete a special event + operationId: deleteSpecialEvent + tags: + - Events + responses: + "204": + description: Special event deleted successfully. + "404": + $ref: "#/components/responses/NotFound" + + /special-events/{id}/publish: + post: + summary: Publish a special event + description: > + Publishes a draft special event, making it visible in the public listing. + Published events cannot be unpublished, only deleted. + operationId: publishSpecialEvent + tags: + - Events + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "202": + description: Special event published successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SpecialEventResponse" + "404": + $ref: "#/components/responses/NotFound" + + /tickets: + post: + summary: Buy museum tickets + description: > + Purchases one or more tickets for a museum visit or special event. Returns a + ticket ID that can be used to retrieve a QR code for entry. + operationId: buyMuseumTickets + tags: + - Tickets + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BuyMuseumTicketsRequest" + responses: + "201": + description: Tickets purchased successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/BuyMuseumTicketsResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /tickets/{ticketId}/qr-code: + get: + summary: Get ticket QR code + description: > + Returns a QR code image for the specified ticket. Present this at the museum + entrance for admission. Renamed from `/tickets/{ticketId}/qr` in v1 — see the v2 + changelog note in this file's `info.description`. + operationId: getTicketQrCode + tags: + - Tickets + parameters: + - name: ticketId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: QR code image returned successfully. + content: + image/png: + schema: + type: string + format: binary + "404": + $ref: "#/components/responses/NotFound" + +components: + schemas: + MuseumDayHours: + type: object + description: Opening hours for a single day. + properties: + date: + type: string + format: date + description: The date these hours apply to. + example: "2024-02-01" + timeOpen: + type: string + description: Opening time (24-hour format). + example: "09:00" + timeClose: + type: string + description: Closing time (24-hour format). + example: "17:00" + required: + - date + - timeOpen + - timeClose + + GetMuseumHoursResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/MuseumDayHours" + + SpecialEventResponse: + type: object + description: A special event at the museum. + properties: + id: + type: string + format: uuid + description: Unique identifier for the event. Renamed from `eventId` in v1. + name: + type: string + description: The name of the event. + example: "Mermaid Legends" + location: + type: string + description: Where the event takes place within the museum. + example: "Section D" + eventDescription: + type: string + description: A description of the event. + dates: + type: array + description: Dates on which the event takes place. + items: + type: string + format: date + price: + type: number + format: float + description: Ticket price in USD. + example: 25.00 + status: + type: string + enum: [draft, published] + description: Publication status of the event. + required: + - id + - name + - location + - dates + - price + - status + + ListSpecialEventsResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/SpecialEventResponse" + pagination: + type: object + properties: + page: + type: integer + limit: + type: integer + total: + type: integer + + CreateSpecialEventRequest: + type: object + required: + - name + - location + - dates + - price + properties: + name: + type: string + example: "Mermaid Legends" + location: + type: string + example: "Section D" + eventDescription: + type: string + dates: + type: array + items: + type: string + format: date + price: + type: number + format: float + example: 25.00 + + UpdateSpecialEventRequest: + type: object + properties: + name: + type: string + location: + type: string + eventDescription: + type: string + dates: + type: array + items: + type: string + format: date + price: + type: number + format: float + + BuyMuseumTicketsRequest: + type: object + required: + - ticketType + - ticketDate + - email + properties: + ticketType: + type: string + enum: [general, special] + description: Type of ticket — general admission or a special event. + ticketDate: + type: string + format: date + description: The date the ticket is valid for. + eventId: + type: string + format: uuid + description: Required when ticketType is "special". Refers to the special event's `id`. + email: + type: string + format: email + description: Email address to send the ticket confirmation to. + + BuyMuseumTicketsResponse: + type: object + properties: + ticketId: + type: string + format: uuid + description: Unique ticket identifier. Use this to retrieve the QR code. + ticketType: + type: string + enum: [general, special] + ticketDate: + type: string + format: date + confirmationCode: + type: string + example: "ticket-ABC123" + required: + - ticketId + - ticketType + - ticketDate + - confirmationCode + + Error: + type: object + properties: + type: + type: string + format: uri + title: + type: string + status: + type: integer + detail: + type: string + required: + - type + - title + - status + + responses: + BadRequest: + description: Bad request — invalid input parameters. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Error" + + NotFound: + description: Resource not found. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Error" diff --git a/labs/lab-06-api-lifecycle-management/catalog/system.yaml b/labs/lab-06-api-lifecycle-management/catalog/system.yaml new file mode 100644 index 0000000..5aff5f1 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/catalog/system.yaml @@ -0,0 +1,18 @@ +# Lab 6 — Logical grouping for all major versions of the Museum API. +# +# A System entity is the native Backstage relation used to say "these API entities are all +# versions of the same logical API" (see labs/lab-06-api-lifecycle-management/README.md for why). +# Non-API entity kinds (including System) remain visible to all authenticated users regardless of +# an API's own visibility annotation — see the permission policy's Rule 1 from Lab 2. + +apiVersion: backstage.io/v1alpha1 +kind: System +metadata: + name: museum-api + description: > + Logical grouping of all major versions of the Museum API. Each major version is + registered as its own API entity (museum-api-v1, museum-api-v2, ...) and linked here + via spec.system, so they can coexist in the catalog while still being presented as + versions of one logical API. +spec: + owner: group:default/museum-team diff --git a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx new file mode 100644 index 0000000..3918827 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { + catalogApiRef, + useEntity, + EntityRefLink, +} from '@backstage/plugin-catalog-react'; +import { InfoCard, Progress, ResponseErrorPanel } from '@backstage/core-components'; +import { Entity } from '@backstage/catalog-model'; +import { + Button, + Chip, + List, + ListItem, + ListItemText, + Typography, +} from '@material-ui/core'; + +const VERSION_ANNOTATION = 'apiportal.io/version'; +const RETIRED = 'retired'; + +function getVersion(entity: Entity): [number, number] { + const raw = entity.metadata.annotations?.[VERSION_ANNOTATION]; + const [major, minor] = (raw ?? '0.0').split('.').map(Number); + return [major || 0, minor || 0]; +} + +function isRetired(entity: Entity): boolean { + return entity.spec?.lifecycle === RETIRED; +} + +function getLifecycle(entity: Entity): string | undefined { + return entity.spec?.lifecycle as string | undefined; +} + +function compareVersions(a: Entity, b: Entity): number { + const [aMajor, aMinor] = getVersion(a); + const [bMajor, bMinor] = getVersion(b); + return aMajor !== bMajor ? aMajor - bMajor : aMinor - bMinor; +} + +// "Latest" is computed from every sibling's version annotation, not manually flagged, to +// avoid two versions ever claiming "latest" at once (research.md R2). Prefer the highest +// non-retired version; if every sibling is retired, fall back to the highest overall +// (spec.md edge case: no current version at all). +function findLatest(siblings: Entity[]): Entity | undefined { + const active = siblings.filter(s => !isRetired(s)); + const pool = active.length > 0 ? active : siblings; + return [...pool].sort(compareVersions).pop(); +} + +function VersionRow({ + sibling, + isCurrent, + isLatest, +}: { + sibling: Entity; + isCurrent: boolean; + isLatest: boolean; +}) { + const [major, minor] = getVersion(sibling); + const lifecycle = getLifecycle(sibling); + return ( + + + {isCurrent ? ( + + {sibling.metadata.name} — v{major}.{minor} (this page) + + ) : ( + + {sibling.metadata.name} — v{major}.{minor} + + )} + {isLatest && } + {lifecycle && ( + + )} + + } + /> + + ); +} + +export function ApiVersionsCard() { + const { entity } = useEntity(); + const catalogApi = useApi(catalogApiRef); + const [siblings, setSiblings] = useState(); + const [error, setError] = useState(); + const [showRetired, setShowRetired] = useState(false); + + const system = entity.spec?.system as string | undefined; + + useEffect(() => { + if (!system) { + setSiblings([]); + return; + } + catalogApi + .getEntities({ + filter: { + kind: 'API', + 'spec.system': system, + }, + }) + .then(response => setSiblings(response.items)) + .catch(setError); + }, [catalogApi, system]); + + if (!system) { + return ( + + + This API is not part of a System, so no other versions are known. + + + ); + } + + if (error) { + return ( + + + + ); + } + + if (!siblings) { + return ( + + + + ); + } + + const sorted = [...siblings].sort(compareVersions).reverse(); + const latest = findLatest(siblings); + + // Retired versions are collapsed by default (research.md R4/R5) — still in the catalog, + // still reachable, just not part of the default view a learner sees first. + const active = sorted.filter(sibling => !isRetired(sibling)); + const retired = sorted.filter(sibling => isRetired(sibling)); + + return ( + + + {active.map(sibling => ( + + ))} + {retired.length > 0 && showRetired && + retired.map(sibling => ( + + ))} + + {retired.length > 0 && ( + + )} + + ); +} diff --git a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/index.ts b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/index.ts new file mode 100644 index 0000000..684a5d7 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/index.ts @@ -0,0 +1,18 @@ +import { createFrontendModule } from '@backstage/frontend-plugin-api'; +import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha'; +import React from 'react'; +import { ApiVersionsCard } from './ApiVersionsCard'; + +const apiVersionsCard = EntityCardBlueprint.make({ + name: 'api-versions', + params: { + filter: 'kind:API', + type: 'info', + loader: async () => React.createElement(ApiVersionsCard), + }, +}); + +export const apiVersionsModule = createFrontendModule({ + pluginId: 'catalog', + extensions: [apiVersionsCard], +}); diff --git a/specs/006-api-lifecycle-management/tasks.md b/specs/006-api-lifecycle-management/tasks.md index d9ac90e..ace3f84 100644 --- a/specs/006-api-lifecycle-management/tasks.md +++ b/specs/006-api-lifecycle-management/tasks.md @@ -36,7 +36,7 @@ This is a tutorial-lab project (not a generic web/mobile app). Three path roots **Purpose**: Scaffold the directories every later task writes into. No new dependencies are needed (research.md — zero new frontend/root packages). -- [ ] T001 Create the lab content directories `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/` and `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/` +- [X] T001 Create the lab content directories `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/` and `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/` **Checkpoint**: Directories exist — ready for foundational work. @@ -48,13 +48,13 @@ This is a tutorial-lab project (not a generic web/mobile app). Three path roots **⚠️ CRITICAL**: No user story work can begin until this phase is complete. -- [ ] T002 [P] Create the `System` entity `system:default/museum-api` in `labs/lab-06-api-lifecycle-management/catalog/system.yaml` (owner: `group:default/museum-team`, per data-model.md) -- [ ] T003 [P] Create `museum-api-v1.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/`: `metadata.name: museum-api-v1`, `annotations['apiportal.io/version']: "1.0"`, `example.com/visibility: private` (unchanged from Lab 2), `spec.lifecycle: production`, `spec.system: museum-api`, `spec.definition.$text` pointing at Lab 1's existing, unmodified `labs/lab-01-base-backstage/apis/museum/openapi.yaml` raw URL (research.md R6, data-model.md) -- [ ] T004 [P] Author `museum-v2/openapi.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/`: a deliberately-breaking variant of the Museum API — rename the `eventId` path parameter to `id` across the `/special-events/{eventId}` family of operations, and remove or rename the `/tickets/{ticketId}/qr` endpoint — with `info.version: 2.0.0` (research.md R6) -- [ ] T005 Create `museum-api-v2.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/`: `metadata.name: museum-api-v2`, `annotations['apiportal.io/version']: "2.0"`, `example.com/visibility: private`, `spec.lifecycle: development`, `spec.system: museum-api`, `spec.definition.$text` pointing at T004's new spec file's raw URL (depends on T004) -- [ ] T006 Update `labs/lab-01-base-backstage/backstage/app-config.yaml`'s `catalog.locations`: remove Lab 2's single `museum-api.yaml` location entry, add three new `type: url` entries for `system.yaml`, `museum-api-v1.yaml`, and `museum-api-v2.yaml` (research.md R6, depends on T002, T003, T005) -- [ ] T007 Scaffold the `apiVersions` frontend module as an `EntityCardBlueprint` (`filter: 'kind:API'`, `type: 'info'`) rendering a placeholder `ApiVersionsCard` that shows only the current entity's own name and `apiportal.io/version` annotation — no sibling lookup yet — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/{index.ts,ApiVersionsCard.tsx}` (research.md R4, same `EntityCardBlueprint` pattern as Lab 2's `apiVisibility`/Lab 3's `apiGrade`) -- [ ] T008 Copy the scaffolded module to `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/` and register `apiVersionsModule` in `labs/lab-01-base-backstage/backstage/packages/app/src/App.tsx`'s `features` array (depends on T007) +- [X] T002 [P] Create the `System` entity `system:default/museum-api` in `labs/lab-06-api-lifecycle-management/catalog/system.yaml` (owner: `group:default/museum-team`, per data-model.md) +- [X] T003 [P] Create `museum-api-v1.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/`: `metadata.name: museum-api-v1`, `annotations['apiportal.io/version']: "1.0"`, `example.com/visibility: private` (unchanged from Lab 2), `spec.lifecycle: production`, `spec.system: museum-api`, `spec.definition.$text` pointing at Lab 1's existing, unmodified `labs/lab-01-base-backstage/apis/museum/openapi.yaml` raw URL (research.md R6, data-model.md) +- [X] T004 [P] Author `museum-v2/openapi.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/`: a deliberately-breaking variant of the Museum API — rename the `eventId` path parameter to `id` across the `/special-events/{eventId}` family of operations, and remove or rename the `/tickets/{ticketId}/qr` endpoint — with `info.version: 2.0.0` (research.md R6) +- [X] T005 Create `museum-api-v2.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/`: `metadata.name: museum-api-v2`, `annotations['apiportal.io/version']: "2.0"`, `example.com/visibility: private`, `spec.lifecycle: development`, `spec.system: museum-api`, `spec.definition.$text` pointing at T004's new spec file's raw URL (depends on T004) +- [X] T006 Update `labs/lab-01-base-backstage/backstage/app-config.yaml`'s `catalog.locations`: remove Lab 2's single `museum-api.yaml` location entry, add three new `type: url` entries for `system.yaml`, `museum-api-v1.yaml`, and `museum-api-v2.yaml` (research.md R6, depends on T002, T003, T005) +- [X] T007 Scaffold the `apiVersions` frontend module as an `EntityCardBlueprint` (`filter: 'kind:API'`, `type: 'info'`) rendering a placeholder `ApiVersionsCard` that shows only the current entity's own name and `apiportal.io/version` annotation — no sibling lookup yet — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/{index.ts,ApiVersionsCard.tsx}` (research.md R4, same `EntityCardBlueprint` pattern as Lab 2's `apiVisibility`/Lab 3's `apiGrade`) +- [X] T008 Copy the scaffolded module to `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/` and register `apiVersionsModule` in `labs/lab-01-base-backstage/backstage/packages/app/src/App.tsx`'s `features` array (depends on T007) **Checkpoint**: Both API versions and their System are registered and related; the card scaffold renders without error — ready for US1. @@ -68,9 +68,9 @@ This is a tutorial-lab project (not a generic web/mobile app). Three path roots ### Implementation for User Story 1 -- [ ] T009 [US1] Implement sibling-lookup logic in `ApiVersionsCard.tsx`: read the current entity's `spec.system`, call `catalogApi.getEntities()` (via `useApi(catalogApiRef)`) filtered to `kind: API` entities sharing that `spec.system`, and render each sibling's name/version as a row linking to its own page via `EntityRefLink` — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (data-model.md, research.md R1/R4, depends on T007) -- [ ] T010 [US1] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T009, T008) -- [ ] T011 [US1] Manually verify (quickstart.md steps 4–5): the catalog shows `museum-api-v1`, `museum-api-v2`, and the `museum-api` System as separate entities existing at the same time; the System page lists both as "Has part" APIs; the Versions card on either version's page lists both, and following a link reaches the other (SC-001, depends on T006, T010) +- [X] T009 [US1] Implement sibling-lookup logic in `ApiVersionsCard.tsx`: read the current entity's `spec.system`, call `catalogApi.getEntities()` (via `useApi(catalogApiRef)`) filtered to `kind: API` entities sharing that `spec.system`, and render each sibling's name/version as a row linking to its own page via `EntityRefLink` — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (data-model.md, research.md R1/R4, depends on T007) +- [X] T010 [US1] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T009, T008) +- [X] T011 [US1] Manually verify (quickstart.md steps 4–5): the catalog shows `museum-api-v1`, `museum-api-v2`, and the `museum-api` System as separate entities existing at the same time; the System page lists both as "Has part" APIs; the Versions card on either version's page lists both, and following a link reaches the other (SC-001, depends on T006, T010) **Checkpoint**: User Story 1 fully functional and independently testable — this is the MVP. @@ -84,9 +84,9 @@ This is a tutorial-lab project (not a generic web/mobile app). Three path roots ### Implementation for User Story 2 -- [ ] T012 [US2] Add "Latest" computation to `ApiVersionsCard.tsx`: parse each sibling's `apiportal.io/version` annotation as a `[major, minor]` pair, compare numerically among siblings whose `spec.lifecycle !== 'retired'` (falling back to comparing all siblings if every one is retired, per spec.md's edge case), and render a "Latest" badge on the highest — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R2, depends on T009) -- [ ] T013 [US2] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T012, T010) -- [ ] T014 [US2] Manually verify (quickstart.md step 5): v2 is flagged Latest in the card on both v1 and v2's pages; v1 is reachable from v2's card within one click; navigating directly to `museum-api-v1` by name still resolves and opens its page (SC-002, depends on T013) +- [X] T012 [US2] Add "Latest" computation to `ApiVersionsCard.tsx`: parse each sibling's `apiportal.io/version` annotation as a `[major, minor]` pair, compare numerically among siblings whose `spec.lifecycle !== 'retired'` (falling back to comparing all siblings if every one is retired, per spec.md's edge case), and render a "Latest" badge on the highest — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R2, depends on T009) +- [X] T013 [US2] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T012, T010) +- [X] T014 [US2] Manually verify (quickstart.md step 5): v2 is flagged Latest in the card on both v1 and v2's pages; v1 is reachable from v2's card within one click; navigating directly to `museum-api-v1` by name still resolves and opens its page (SC-002, depends on T013) **Checkpoint**: User Stories 1 AND 2 both work independently — versions coexist, are related, and the latest is prominent. @@ -100,10 +100,10 @@ This is a tutorial-lab project (not a generic web/mobile app). Three path roots ### Implementation for User Story 3 -- [ ] T015 [US3] Add a lifecycle chip to each sibling row in `ApiVersionsCard.tsx`, reusing each entity's existing `spec.lifecycle` value (no new field) — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R3, depends on T012) -- [ ] T016 [US3] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T015, T013) -- [ ] T017 [US3] Manually verify (quickstart.md step 5): v1's own About card and its row in the Versions card both show `production`; v2's both show `development`; the two differ (SC-003, depends on T016) -- [ ] T018 [US3] Edit `museum-api-v2.yaml`'s `spec.lifecycle` from `development` to `testing`, then to `production` (two sequential edits); after each, refresh and confirm the displayed chip (own page and Versions card) updates to match, and that v1's entry is unaffected (SC-004, quickstart.md step 6, depends on T017) +- [X] T015 [US3] Add a lifecycle chip to each sibling row in `ApiVersionsCard.tsx`, reusing each entity's existing `spec.lifecycle` value (no new field) — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R3, depends on T012) +- [X] T016 [US3] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T015, T013) +- [X] T017 [US3] Manually verify (quickstart.md step 5): v1's own About card and its row in the Versions card both show `production`; v2's both show `development`; the two differ (SC-003, depends on T016) +- [X] T018 [US3] Edit `museum-api-v2.yaml`'s `spec.lifecycle` from `development` to `testing`, then to `production` (two sequential edits); after each, refresh and confirm the displayed chip (own page and Versions card) updates to match, and that v1's entry is unaffected (SC-004, quickstart.md step 6, depends on T017) **Checkpoint**: All three user stories independently functional — versions coexist, latest is prominent, and lifecycle is tracked per version. @@ -117,10 +117,10 @@ This is a tutorial-lab project (not a generic web/mobile app). Three path roots ### Implementation for User Story 4 -- [ ] T019 [US4] Add retired-version collapsing to `ApiVersionsCard.tsx`: siblings with `spec.lifecycle === 'retired'` are grouped behind a closed-by-default "Show retired versions (N)" toggle (local component state), each still rendering its Deprecated/Retired lifecycle chip and working link when expanded — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R4/R5, depends on T015) -- [ ] T020 [US4] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T019, T016) -- [ ] T021 [US4] Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `deprecated`; confirm the Deprecated label appears on v1's own page and in the Versions card on both v1 and v2's pages (quickstart.md step 7, depends on T020) -- [ ] T022 [US4] Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `retired`; confirm v1 now only appears in the Versions card behind the "Show retired versions" toggle (collapsed by default) on both pages, while a direct link to `museum-api-v1` still opens it, clearly labeled Retired; confirm the entity itself is still present in the catalog — never deleted (SC-005, SC-006, quickstart.md steps 8–9, depends on T021) +- [X] T019 [US4] Add retired-version collapsing to `ApiVersionsCard.tsx`: siblings with `spec.lifecycle === 'retired'` are grouped behind a closed-by-default "Show retired versions (N)" toggle (local component state), each still rendering its Deprecated/Retired lifecycle chip and working link when expanded — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R4/R5, depends on T015) +- [X] T020 [US4] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T019, T016) +- [X] T021 [US4] Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `deprecated`; confirm the Deprecated label appears on v1's own page and in the Versions card on both v1 and v2's pages (quickstart.md step 7, depends on T020) +- [X] T022 [US4] Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `retired`; confirm v1 now only appears in the Versions card behind the "Show retired versions" toggle (collapsed by default) on both pages, while a direct link to `museum-api-v1` still opens it, clearly labeled Retired; confirm the entity itself is still present in the catalog — never deleted (SC-005, SC-006, quickstart.md steps 8–9, depends on T021) **Checkpoint**: All four user stories independently functional — this is the full lab. @@ -130,14 +130,14 @@ This is a tutorial-lab project (not a generic web/mobile app). Three path roots **Purpose**: Learner-facing documentation and final repo-wide consistency checks that span multiple stories. -- [ ] T023 Write `labs/lab-06-api-lifecycle-management/README.md` Overview/Prerequisites/Steps sections (Constitution Principle II) covering: add the System/v1/v2 catalog entries and v2 spec, update `app-config.yaml` locations, scaffold/register the `apiVersions` module, start Backstage, and walk through latest-prominence, lifecycle-progression, and deprecate-then-retire verification — following quickstart.md steps 1–9, and linking to the committed `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml` and `code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` rather than inlining them (both exceed the ~40–50 line threshold) (depends on T011, T014, T018, T022) -- [ ] T024 Add the README's "Why a System entity, not a custom annotation" and "Why latest is computed, not hand-flagged" sections (research.md R1, R2) -- [ ] T025 [P] Add the README's "Why Backstage's native search/catalog table is left unmodified" section (research.md R5) — framed as a documented design boundary, not a gap, per Constitution Principle VIII -- [ ] T026 [P] Add the README's "Adaptable conventions vs. fixed mechanics" section (FR-013): the `apiportal.io/version` format, the five-value lifecycle convention, and the "one System per logical API" pattern are adaptable; the `System`/`spec.system` relation and the `EntityCardBlueprint` mechanism itself are fixed -- [ ] T027 Add the README's Verification and Troubleshooting sections (Lab Structure Standards) covering the manual checks already performed in T011/T014/T017/T018/T021/T022, plus common failure modes (forgetting to remove Lab 2's old `museum-api.yaml` catalog location, a `spec.system` typo silently producing an empty Versions card) -- [ ] T028 Update root `README.md` (Lab Series table entry, Getting Started tree, Repository Structure tree) to reference `labs/lab-06-api-lifecycle-management/` and `specs/006-api-lifecycle-management/`, per the Constitution's Lab Structure Standards — required for this lab to be considered complete -- [ ] T029 [P] Re-verify the Constitution Check table in plan.md against the final implementation (all 9 principles, with particular attention to Principle VI's "breaking change to the environment" justification and Principle VIII's scale claims) and correct plan.md if any gate's justification no longer matches what was built -- [ ] T030 Run the full quickstart.md validation sequence (steps 1–9) end-to-end against a clean `yarn start` of `labs/lab-01-base-backstage/backstage/` to confirm the lab works from a cold start, not just incrementally during development +- [X] T023 Write `labs/lab-06-api-lifecycle-management/README.md` Overview/Prerequisites/Steps sections (Constitution Principle II) covering: add the System/v1/v2 catalog entries and v2 spec, update `app-config.yaml` locations, scaffold/register the `apiVersions` module, start Backstage, and walk through latest-prominence, lifecycle-progression, and deprecate-then-retire verification — following quickstart.md steps 1–9, and linking to the committed `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml` and `code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` rather than inlining them (both exceed the ~40–50 line threshold) (depends on T011, T014, T018, T022) +- [X] T024 Add the README's "Why a System entity, not a custom annotation" and "Why latest is computed, not hand-flagged" sections (research.md R1, R2) +- [X] T025 [P] Add the README's "Why Backstage's native search/catalog table is left unmodified" section (research.md R5) — framed as a documented design boundary, not a gap, per Constitution Principle VIII +- [X] T026 [P] Add the README's "Adaptable conventions vs. fixed mechanics" section (FR-013): the `apiportal.io/version` format, the five-value lifecycle convention, and the "one System per logical API" pattern are adaptable; the `System`/`spec.system` relation and the `EntityCardBlueprint` mechanism itself are fixed +- [X] T027 Add the README's Verification and Troubleshooting sections (Lab Structure Standards) covering the manual checks already performed in T011/T014/T017/T018/T021/T022, plus common failure modes (forgetting to remove Lab 2's old `museum-api.yaml` catalog location, a `spec.system` typo silently producing an empty Versions card) +- [X] T028 Update root `README.md` (Lab Series table entry, Getting Started tree, Repository Structure tree) to reference `labs/lab-06-api-lifecycle-management/` and `specs/006-api-lifecycle-management/`, per the Constitution's Lab Structure Standards — required for this lab to be considered complete +- [X] T029 [P] Re-verify the Constitution Check table in plan.md against the final implementation (all 9 principles, with particular attention to Principle VI's "breaking change to the environment" justification and Principle VIII's scale claims) and correct plan.md if any gate's justification no longer matches what was built +- [X] T030 Run the full quickstart.md validation sequence (steps 1–9) end-to-end against a clean `yarn start` of `labs/lab-01-base-backstage/backstage/` to confirm the lab works from a cold start, not just incrementally during development --- From 57a7524ef2df07cb5a2a9b83c58e2bcdd8574a16 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 5 Jul 2026 14:30:45 +1000 Subject: [PATCH 03/11] Testing lifecycle advancement --- .../catalog/apis/museum-api-v1.yaml | 2 +- .../catalog/apis/museum-api-v2.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml index 0b98a74..988388f 100644 --- a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml +++ b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml @@ -25,7 +25,7 @@ metadata: apiportal.io/version: "1.0" spec: type: openapi - lifecycle: production + lifecycle: deprecated owner: group:default/museum-team system: museum-api definition: diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml index 0a56b85..c3b3925 100644 --- a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml +++ b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml @@ -22,7 +22,7 @@ metadata: apiportal.io/version: "2.0" spec: type: openapi - lifecycle: development + lifecycle: testing owner: group:default/museum-team system: museum-api definition: From c6aafc8fa551fcb1437d11710728beffcb24f89c Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 5 Jul 2026 14:36:09 +1000 Subject: [PATCH 04/11] Force update --- .../catalog/apis/museum-api-v1.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml index 988388f..5b12762 100644 --- a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml +++ b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml @@ -13,7 +13,7 @@ metadata: description: > A sample REST API for a fictional museum, demonstrating modern OpenAPI 3.1 design practices. Covers special events, museum hours, and ticket purchasing. Version 1 — - see museum-api-v2 for the next major version and its breaking changes. + see museum-api-v2 for the next major version and its breaking changes tags: - rest - openapi From 41d3652e111a5aa5cff123583d2a6f8eb898c177 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 5 Jul 2026 14:38:42 +1000 Subject: [PATCH 05/11] Retire old version --- .../catalog/apis/museum-api-v1.yaml | 2 +- .../catalog/apis/museum-api-v2.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml index 5b12762..a34d24f 100644 --- a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml +++ b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml @@ -25,7 +25,7 @@ metadata: apiportal.io/version: "1.0" spec: type: openapi - lifecycle: deprecated + lifecycle: retired owner: group:default/museum-team system: museum-api definition: diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml index c3b3925..7cd0248 100644 --- a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml +++ b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml @@ -22,7 +22,7 @@ metadata: apiportal.io/version: "2.0" spec: type: openapi - lifecycle: testing + lifecycle: production owner: group:default/museum-team system: museum-api definition: From 5e30fca5136411f822617c6e3f22ae89f526e9ae Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 5 Jul 2026 18:13:22 +1000 Subject: [PATCH 06/11] Testing deprecated APIs --- .../catalog/apis/museum-api-v1.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml index a34d24f..5b12762 100644 --- a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml +++ b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml @@ -25,7 +25,7 @@ metadata: apiportal.io/version: "1.0" spec: type: openapi - lifecycle: retired + lifecycle: deprecated owner: group:default/museum-team system: museum-api definition: From 8e0da231b837428744fe89841173e2e199ee9dbb Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 5 Jul 2026 18:26:22 +1000 Subject: [PATCH 07/11] Deprecation and retirement visibility improved --- .gitignore | 3 + .../apis/galaxy/AccountService-openapi.yaml | 4942 ----------------- .../lab-06-api-lifecycle-management/README.md | 53 +- .../apiVersions/ApiLifecycleBanner.tsx | 48 + .../modules/apiVersions/ApiVersionsCard.tsx | 71 +- .../app/src/modules/apiVersions/index.ts | 15 +- .../src/modules/apiVersions/versionUtils.ts | 69 + .../checklists/issues.md | 2 + 8 files changed, 190 insertions(+), 5013 deletions(-) delete mode 100644 labs/lab-04-auto-registration/apis/galaxy/AccountService-openapi.yaml create mode 100644 labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiLifecycleBanner.tsx create mode 100644 labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts create mode 100644 specs/006-api-lifecycle-management/checklists/issues.md diff --git a/.gitignore b/.gitignore index 0f9922d..52867ae 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Backstage apps created by students during labs (gitignored — large, student-generated) labs/*/backstage/ +# Local Files +**/.local/* + # Node.js node_modules/ npm-debug.log* diff --git a/labs/lab-04-auto-registration/apis/galaxy/AccountService-openapi.yaml b/labs/lab-04-auto-registration/apis/galaxy/AccountService-openapi.yaml deleted file mode 100644 index b0d5522..0000000 --- a/labs/lab-04-auto-registration/apis/galaxy/AccountService-openapi.yaml +++ /dev/null @@ -1,4942 +0,0 @@ -openapi: 3.1.0 -servers: -- url: https://cal-test.adyen.com/cal/services/Account/v6 -info: - version: '6' - x-publicVersion: true - title: Account API - description: "This API is used for the classic integration. If you are just starting\ - \ your implementation, refer to our [new integration guide](https://docs.adyen.com/adyen-for-platforms-model)\ - \ instead.\n\nThe Account API provides endpoints for managing account-related\ - \ entities on your platform. These related entities include account holders, accounts,\ - \ bank accounts, shareholders, and verification-related documents. The management\ - \ operations include actions such as creation, retrieval, updating, and deletion\ - \ of them.\n\nFor more information, refer to our [documentation](https://docs.adyen.com/classic-platforms).\n\ - ## Authentication\nYour Adyen contact will provide your API credential and an\ - \ API key. To connect to the API, add an `X-API-Key` header with the API key as\ - \ the value, for example:\n\n ```\ncurl\n-H \"Content-Type: application/json\"\ - \ \\\n-H \"X-API-Key: YOUR_API_KEY\" \\\n...\n```\n\nAlternatively, you can use\ - \ the username and password to connect to the API using basic authentication.\ - \ For example:\n\n```\ncurl\n-U \"ws@MarketPlace.YOUR_PLATFORM_ACCOUNT\":\"YOUR_WS_PASSWORD\"\ - \ \\\n-H \"Content-Type: application/json\" \\\n...\n```\nWhen going live, you\ - \ need to generate new web service user credentials to access the [live endpoints](https://docs.adyen.com/development-resources/live-endpoints).\n\ - \n## Versioning\nThe Account API supports [versioning](https://docs.adyen.com/development-resources/versioning)\ - \ using a version suffix in the endpoint URL. This suffix has the following format:\ - \ \"vXX\", where XX is the version number.\n\nFor example:\n```\nhttps://cal-test.adyen.com/cal/services/Account/v6/createAccountHolder\n\ - ```" - termsOfService: https://www.adyen.com/legal/terms-and-conditions - contact: - name: Adyen Developer Experience team - url: https://github.com/Adyen/adyen-openapi -tags: -- name: Account holders -- name: Accounts -- name: Verification -paths: - /checkAccountHolder: - post: - tags: - - Verification - summary: Trigger verification - description: Triggers the verification of an account holder even if the checks - are not yet required for the volume that they are currently processing. - x-addedInVersion: '5' - operationId: post-checkAccountHolder - x-sortIndex: 3 - x-methodName: checkAccountHolder - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-checkAccountHolder-basic' - schema: - $ref: '#/components/schemas/PerformVerificationRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /closeAccount: - post: - tags: - - Accounts - summary: Close an account - description: Closes an account. If an account is closed, you cannot process - transactions, pay out its funds, or reopen it. If payments are made to a closed - account, the payments are sent to your liable account. - operationId: post-closeAccount - x-sortIndex: 3 - x-methodName: closeAccount - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - closeAccount: - $ref: '#/components/examples/post-closeAccount-closeAccount' - schema: - $ref: '#/components/schemas/CloseAccountRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CloseAccountResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/CloseAccountResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /closeAccountHolder: - post: - tags: - - Account holders - summary: Close an account holder - description: Changes the [status of an account holder](https://docs.adyen.com/classic-platforms/account-holders-and-accounts#account-holder-statuses) - to **Closed**. This state is final. If an account holder is closed, you can't - process transactions, pay out funds, or reopen it. If payments are made to - an account of an account holder with a **Closed** [`status`](https://docs.adyen.com/api-explorer/#/Account/latest/post/getAccountHolder__resParam_verification-accountHolder-checks-status), - the payments are sent to your liable account. - operationId: post-closeAccountHolder - x-sortIndex: 7 - x-methodName: closeAccountHolder - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-closeAccountHolder-basic' - schema: - $ref: '#/components/schemas/CloseAccountHolderRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CloseAccountHolderResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/CloseAccountHolderResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /closeStores: - post: - tags: - - Account holders - summary: Close stores - description: Closes stores associated with an account holder. - x-addedInVersion: '5' - operationId: post-closeStores - x-sortIndex: 9 - x-methodName: closeStores - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/CloseStoresRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: OK - the request has succeeded. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /createAccount: - post: - tags: - - Accounts - summary: Create an account - description: Creates an account under an account holder. An account holder can - have [multiple accounts](https://docs.adyen.com/classic-platforms/account-holders-and-accounts#create-additional-accounts). - operationId: post-createAccount - x-sortIndex: 1 - x-methodName: createAccount - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-createAccount-basic' - schema: - $ref: '#/components/schemas/CreateAccountRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/CreateAccountResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/CreateAccountResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /createAccountHolder: - post: - tags: - - Account holders - summary: Create an account holder - description: Creates an account holder that [represents the sub-merchant's entity](https://docs.adyen.com/classic-platforms/account-structure#your-platform) - in your platform. The details that you need to provide in the request depend - on the sub-merchant's legal entity type. For more information, refer to [Account - holder and accounts](https://docs.adyen.com/classic-platforms/account-holders-and-accounts#legal-entity-types). - operationId: post-createAccountHolder - x-sortIndex: 1 - x-methodName: createAccountHolder - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - business: - $ref: '#/components/examples/post-createAccountHolder-business' - individual: - $ref: '#/components/examples/post-createAccountHolder-individual' - schema: - $ref: '#/components/schemas/CreateAccountHolderRequest' - responses: - '200': - content: - application/json: - examples: - business: - $ref: '#/components/examples/post-createAccountHolder-business-200' - individual: - $ref: '#/components/examples/post-createAccountHolder-individual-200' - schema: - $ref: '#/components/schemas/CreateAccountHolderResponse' - description: OK - the request has succeeded. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /deleteBankAccounts: - post: - tags: - - Verification - summary: Delete bank accounts - description: 'Deletes bank accounts associated with an account holder. ' - operationId: post-deleteBankAccounts - x-sortIndex: 4 - x-methodName: deleteBankAccounts - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-deleteBankAccounts-basic' - schema: - $ref: '#/components/schemas/DeleteBankAccountRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /deleteLegalArrangements: - post: - tags: - - Verification - summary: Delete legal arrangements - description: Deletes legal arrangements and/or legal arrangement entities associated - with an account holder. - operationId: post-deleteLegalArrangements - x-sortIndex: 6 - x-methodName: deleteLegalArrangements - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - arrangements: - $ref: '#/components/examples/post-deleteLegalArrangements-arrangements' - entities: - $ref: '#/components/examples/post-deleteLegalArrangements-entities' - schema: - $ref: '#/components/schemas/DeleteLegalArrangementRequest' - responses: - '200': - content: - application/json: - examples: - arrangements: - $ref: '#/components/examples/post-deleteLegalArrangements-arrangements-200' - entities: - $ref: '#/components/examples/post-deleteLegalArrangements-entities-200' - schema: - $ref: '#/components/schemas/GenericResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - arrangements: - $ref: '#/components/examples/post-deleteLegalArrangements-arrangements-400' - entities: - $ref: '#/components/examples/post-deleteLegalArrangements-entities-400' - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /deletePayoutMethods: - post: - tags: - - Verification - summary: Delete payout methods - description: Deletes payout methods associated with an account holder. - x-addedInVersion: '5' - operationId: post-deletePayoutMethods - x-sortIndex: 5 - x-methodName: deletePayoutMethods - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-deletePayoutMethods-basic' - schema: - $ref: '#/components/schemas/DeletePayoutMethodRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /deleteShareholders: - post: - tags: - - Verification - summary: Delete shareholders - description: Deletes shareholders associated with an account holder. - operationId: post-deleteShareholders - x-sortIndex: 7 - x-methodName: deleteShareholders - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-deleteShareholders-basic' - schema: - $ref: '#/components/schemas/DeleteShareholderRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /deleteSignatories: - post: - tags: - - Verification - summary: Delete signatories - description: Deletes signatories associated with an account holder. - operationId: post-deleteSignatories - x-sortIndex: 8 - x-methodName: deleteSignatories - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteSignatoriesRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/GenericResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /getAccountHolder: - post: - tags: - - Account holders - summary: Get an account holder - description: Returns the details of an account holder. - operationId: post-getAccountHolder - x-sortIndex: 2 - x-methodName: getAccountHolder - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - accountCode: - $ref: '#/components/examples/post-getAccountHolder-accountCode' - accountHolderCode: - $ref: '#/components/examples/post-getAccountHolder-accountHolderCode' - schema: - $ref: '#/components/schemas/GetAccountHolderRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GetAccountHolderResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/GetAccountHolderResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /getTaxForm: - post: - tags: - - Account holders - summary: Get a tax form - description: Generates a tax form for account holders operating in the US. For - more information, refer to [Providing tax forms](https://docs.adyen.com/classic-platforms/tax-forms). - operationId: post-getTaxForm - x-sortIndex: 8 - x-methodName: getTaxForm - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-getTaxForm-basic' - schema: - $ref: '#/components/schemas/GetTaxFormRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GetTaxFormResponse' - description: OK - the request has succeeded. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /getUploadedDocuments: - post: - tags: - - Verification - summary: Get documents - description: 'Returns documents that were previously uploaded for an account - holder. Adyen uses the documents during the [verification process](https://docs.adyen.com/classic-platforms/verification-process). - - ' - operationId: post-getUploadedDocuments - x-sortIndex: 2 - x-methodName: getUploadedDocuments - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-getUploadedDocuments-basic' - schema: - $ref: '#/components/schemas/GetUploadedDocumentsRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GetUploadedDocumentsResponse' - description: OK - the request has succeeded. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /suspendAccountHolder: - post: - tags: - - Account holders - summary: Suspend an account holder - description: Changes the [status of an account holder](https://docs.adyen.com/classic-platforms/account-holders-and-accounts#account-holder-statuses) - to **Suspended**. - operationId: post-suspendAccountHolder - x-sortIndex: 5 - x-methodName: suspendAccountHolder - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-suspendAccountHolder-basic' - schema: - $ref: '#/components/schemas/SuspendAccountHolderRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/SuspendAccountHolderResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/SuspendAccountHolderResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /unSuspendAccountHolder: - post: - tags: - - Account holders - summary: Unsuspend an account holder - description: "Changes the [status of an account holder](https://docs.adyen.com/classic-platforms/account-holders-and-accounts#account-holder-statuses)\ - \ from **Suspended** to **Inactive**. \nAccount holders can have a **Suspended**\ - \ [`status`](https://docs.adyen.com/api-explorer/#/Account/latest/post/getAccountHolder__resParam_verification-accountHolder-checks-status)\ - \ if you suspend them through the [`/suspendAccountHolder`](https://docs.adyen.com/api-explorer/#/Account/v5/post/suspendAccountHolder)\ - \ endpoint or if a verification deadline expires.\n\nYou can only unsuspend\ - \ account holders if they do not have verification checks with a **FAILED**\ - \ [`status`](https://docs.adyen.com/api-explorer/#/Account/latest/post/getAccountHolder__resParam_verification-accountHolder-checks-status)." - operationId: post-unSuspendAccountHolder - x-sortIndex: 6 - x-methodName: unSuspendAccountHolder - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-unSuspendAccountHolder-basic' - schema: - $ref: '#/components/schemas/UnSuspendAccountHolderRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/UnSuspendAccountHolderResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/UnSuspendAccountHolderResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /updateAccount: - post: - tags: - - Accounts - summary: Update an account - description: Updates the description or payout schedule of an account. - operationId: post-updateAccount - x-sortIndex: 2 - x-methodName: updateAccount - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-updateAccount-basic' - schema: - $ref: '#/components/schemas/UpdateAccountRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateAccountResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateAccountResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /updateAccountHolder: - post: - tags: - - Account holders - summary: Update an account holder - description: "Updates the `accountHolderDetails` and `processingTier` of an\ - \ account holder, and adds bank accounts and shareholders.\n\nWhen updating\ - \ `accountHolderDetails`, parameters that are not included in the request\ - \ are left unchanged except for the following object:\n\n* `metadata`: Updating\ - \ the metadata replaces the entire object. This means that to update an existing\ - \ key-value pair, you must provide the changes, as well as other existing\ - \ key-value pairs.\n\nWhen updating any field in the following objects, you\ - \ must submit all the fields required for validation:\n\n * `address`\n\n\ - * `fullPhoneNumber`\n\n* `bankAccountDetails.BankAccountDetail`\n\n* `businessDetails.shareholders.ShareholderContact`\n\ - \n For example, to update the `address.postalCode`, you must also submit the\ - \ `address.country`, `.city`, `.street`, `.postalCode`, and possibly `.stateOrProvince`\ - \ so that the address can be validated.\n\nTo add a bank account or shareholder,\ - \ provide the bank account or shareholder details without a `bankAccountUUID`\ - \ or a `shareholderCode`.\n\n" - operationId: post-updateAccountHolder - x-sortIndex: 3 - x-methodName: updateAccountHolder - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - addShareholders: - $ref: '#/components/examples/post-updateAccountHolder-addShareholders' - bankAccountDetails: - $ref: '#/components/examples/post-updateAccountHolder-bankAccountDetails' - businessDetails: - $ref: '#/components/examples/post-updateAccountHolder-businessDetails' - general: - $ref: '#/components/examples/post-updateAccountHolder-general' - schema: - $ref: '#/components/schemas/UpdateAccountHolderRequest' - responses: - '200': - content: - application/json: - examples: - addShareholders: - $ref: '#/components/examples/post-updateAccountHolder-addShareholders-200' - bankAccountDetails: - $ref: '#/components/examples/post-updateAccountHolder-bankAccountDetails-200' - businessDetails: - $ref: '#/components/examples/post-updateAccountHolder-businessDetails-200' - general: - $ref: '#/components/examples/post-updateAccountHolder-general-200' - schema: - $ref: '#/components/schemas/UpdateAccountHolderResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateAccountHolderResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /updateAccountHolderState: - post: - tags: - - Account holders - summary: Update payout or processing state - description: Disables or enables the processing or payout state of an account - holder. - operationId: post-updateAccountHolderState - x-sortIndex: 4 - x-methodName: updateAccountHolderState - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-updateAccountHolderState-basic' - schema: - $ref: '#/components/schemas/UpdateAccountHolderStateRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/GetAccountHolderStatusResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/GetAccountHolderStatusResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. - /uploadDocument: - post: - tags: - - Verification - summary: Upload a document - description: Uploads a document for an account holder. Adyen uses the documents - during the [verification process](https://docs.adyen.com/classic-platforms/verification-process). - operationId: post-uploadDocument - x-sortIndex: 1 - x-methodName: uploadDocument - security: - - BasicAuth: [] - - ApiKeyAuth: [] - requestBody: - content: - application/json: - examples: - basic: - $ref: '#/components/examples/post-uploadDocument-basic' - schema: - $ref: '#/components/schemas/UploadDocumentRequest' - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateAccountHolderResponse' - description: OK - the request has succeeded. - '202': - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateAccountHolderResponse' - description: Accepted - the request has been accepted for processing, but - the processing has not been completed. - '400': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-400' - schema: - $ref: '#/components/schemas/ServiceError' - description: Bad Request - a problem reading or understanding the request. - '401': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unauthorized - authentication required. - '403': - content: - application/json: - examples: - generic: - $ref: '#/components/examples/generic-403' - schema: - $ref: '#/components/schemas/ServiceError' - description: Forbidden - insufficient permissions to process the request. - '422': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Unprocessable Entity - a request validation error. - '500': - content: - application/json: - schema: - $ref: '#/components/schemas/ServiceError' - description: Internal Server Error - the server could not process the request. -components: - schemas: - Account: - additionalProperties: false - properties: - accountCode: - description: The code of the account. - type: string - bankAccountUUID: - x-addedInVersion: '5' - description: The bankAccountUUID of the bank account held by the account - holder to couple the account with. Scheduled payouts in currencies matching - the currency of this bank account will be sent to this bank account. Payouts - in different currencies will be sent to a matching bank account of the - account holder. - type: string - beneficiaryAccount: - description: The beneficiary of the account. - type: string - beneficiaryMerchantReference: - description: The reason that a beneficiary has been set up for this account. - This may have been supplied during the setup of a beneficiary at the discretion - of the executing user. - type: string - description: - x-addedInVersion: '4' - description: A description of the account. - type: string - metadata: - x-addedInVersion: '5' - additionalProperties: - type: string - description: 'A set of key and value pairs for general use by the merchant. - - The keys do not have specific names and may be used for storing miscellaneous - data as desired. - - > Note that during an update of metadata, the omission of existing key-value - pairs will result in the deletion of those key-value pairs.' - type: object - payoutMethodCode: - x-addedInVersion: '5' - description: The payout method code held by the account holder to couple - the account with. Scheduled card payouts will be sent using this payout - method code. - type: string - payoutSchedule: - description: The account's payout schedule. - $ref: '#/components/schemas/PayoutScheduleResponse' - payoutSpeed: - x-addedInVersion: '5' - description: 'Speed with which payouts for this account are processed. Permitted - values: `STANDARD`, `SAME_DAY`.' - enum: - - INSTANT - - SAME_DAY - - STANDARD - type: string - status: - x-addedInVersion: '4' - description: 'The status of the account. Possible values: `Active`, `Inactive`, - `Suspended`, `Closed`.' - type: string - type: object - AccountEvent: - additionalProperties: false - properties: - event: - description: 'The event. - - >Permitted values: `InactivateAccount`, `RefundNotPaidOutTransfers`. - - For more information, refer to [Verification checks](https://docs.adyen.com/classic-platforms/verification-process).' - enum: - - InactivateAccount - - RefundNotPaidOutTransfers - type: string - executionDate: - description: The date on which the event will take place. - format: date-time - type: string - reason: - description: The reason why this event has been created. - type: string - type: object - AccountHolderDetails: - additionalProperties: false - properties: - address: - description: The address of the account holder. - $ref: '#/components/schemas/ViasAddress' - bankAccountDetails: - description: Array of bank accounts associated with the account holder. - For details about the required `bankAccountDetail` fields, see [Required - information](https://docs.adyen.com/classic-platforms/verification-process/required-information). - items: - $ref: '#/components/schemas/BankAccountDetail' - type: array - bankAggregatorDataReference: - x-addedInVersion: '5' - description: The opaque reference value returned by the Adyen API during - bank account login. - type: string - businessDetails: - description: 'Details about the business or nonprofit account holder. - - Required when creating an account holder with `legalEntity` **Business** - or **NonProfit**.' - $ref: '#/components/schemas/BusinessDetails' - email: - description: The email address of the account holder. - type: string - fullPhoneNumber: - description: 'The phone number of the account holder provided as a single - string. It will be handled as a landline phone. - - **Examples:** "0031 6 11 22 33 44", "+316/1122-3344", "(0031) 611223344"' - type: string - individualDetails: - description: 'Details about the individual account holder. - - Required when creating an account holder with `legalEntity` **Individual**. - - ' - $ref: '#/components/schemas/IndividualDetails' - lastReviewDate: - description: Date when you last reviewed the account holder's information, - in ISO-8601 YYYY-MM-DD format. For example, **2020-01-31**. - type: string - legalArrangements: - x-addedInVersion: '6' - description: An array containing information about the account holder's - [legal arrangements](https://docs.adyen.com/classic-platforms/verification-process/legal-arrangements). - items: - $ref: '#/components/schemas/LegalArrangementDetail' - type: array - merchantCategoryCode: - description: 'The Merchant Category Code of the account holder. - - > If not specified in the request, this will be derived from the platform - account (which is configured by Adyen).' - type: string - metadata: - additionalProperties: - type: string - description: 'A set of key and value pairs for general use by the account - holder or merchant. - - The keys do not have specific names and may be used for storing miscellaneous - data as desired. - - > The values being stored have a maximum length of eighty (80) characters - and will be truncated if necessary. - - > Note that during an update of metadata, the omission of existing key-value - pairs will result in the deletion of those key-value pairs.' - type: object - payoutMethods: - x-addedInVersion: '5' - description: Array of tokenized card details associated with the account - holder. For details about how you can use the tokens to pay out, refer - to [Pay out to cards](https://docs.adyen.com/classic-platforms/payout-to-cards). - items: - $ref: '#/components/schemas/PayoutMethod' - type: array - phoneNumber: - description: 'The phone number of the account holder. - - > Required if a `fullPhoneNumber` is not provided.' - $ref: '#/components/schemas/ViasPhoneNumber' - principalBusinessAddress: - description: The principal business address of the account holder. - $ref: '#/components/schemas/ViasAddress' - storeDetails: - x-addedInVersion: '5' - description: Array of stores associated with the account holder. Required - when onboarding account holders that have an Adyen [point of sale](https://docs.adyen.com/classic-platforms/platforms-for-pos). - items: - $ref: '#/components/schemas/StoreDetail' - type: array - webAddress: - description: The URL of the website of the account holder. - type: string - required: - - address - type: object - AccountHolderStatus: - additionalProperties: false - properties: - events: - description: A list of events scheduled for the account holder. - items: - $ref: '#/components/schemas/AccountEvent' - type: array - payoutState: - description: The payout state of the account holder. - $ref: '#/components/schemas/AccountPayoutState' - processingState: - description: The processing state of the account holder. - $ref: '#/components/schemas/AccountProcessingState' - status: - description: 'The status of the account holder. - - >Permitted values: `Active`, `Inactive`, `Suspended`, `Closed`.' - enum: - - Active - - Closed - - Inactive - - Suspended - type: string - statusReason: - description: The reason why the status was assigned to the account holder. - type: string - required: - - status - type: object - AccountPayoutState: - additionalProperties: false - properties: - allowPayout: - description: Indicates whether payouts are allowed. This field is the overarching - payout status, and is the aggregate of multiple conditions (e.g., KYC - status, disabled flag, etc). If this field is false, no payouts will be - permitted for any of the account holder's accounts. If this field is true, - payouts will be permitted for any of the account holder's accounts. - type: boolean - disableReason: - description: The reason why payouts (to all of the account holder's accounts) - have been disabled (by the platform). If the `disabled` field is true, - this field can be used to explain why. - type: string - disabled: - description: Indicates whether payouts have been disabled (by the platform) - for all of the account holder's accounts. A platform may enable and disable - this field at their discretion. If this field is true, `allowPayout` will - be false and no payouts will be permitted for any of the account holder's - accounts. If this field is false, `allowPayout` may or may not be enabled, - depending on other factors. - type: boolean - notAllowedReason: - x-addedInVersion: '5' - description: The reason why payouts (to all of the account holder's accounts) - have been disabled (by Adyen). If payouts have been disabled by Adyen, - this field will explain why. If this field is blank, payouts have not - been disabled by Adyen. - type: string - payoutLimit: - description: The maximum amount that payouts are limited to. Only applies - if payouts are allowed but limited. - $ref: '#/components/schemas/Amount' - tierNumber: - x-addedInVersion: '3' - description: The payout tier that the account holder occupies. - format: int32 - type: integer - type: object - AccountProcessingState: - additionalProperties: false - properties: - disableReason: - description: The reason why processing has been disabled. - type: string - disabled: - description: Indicates whether the processing of payments is allowed. - type: boolean - processedFrom: - description: The lower bound of the processing tier (i.e., an account holder - must have processed at least this amount of money in order to be placed - into this tier). - $ref: '#/components/schemas/Amount' - processedTo: - description: The upper bound of the processing tier (i.e., an account holder - must have processed less than this amount of money in order to be placed - into this tier). - $ref: '#/components/schemas/Amount' - tierNumber: - x-addedInVersion: '3' - description: The processing tier that the account holder occupies. - format: int32 - type: integer - type: object - Amount: - additionalProperties: false - properties: - currency: - description: The three-character [ISO currency code](https://docs.adyen.com/development-resources/currency-codes). - maxLength: 3 - minLength: 3 - type: string - value: - description: The amount of the transaction, in [minor units](https://docs.adyen.com/development-resources/currency-codes). - format: int64 - type: integer - required: - - value - - currency - type: object - BankAccountDetail: - additionalProperties: false - properties: - accountNumber: - description: 'The bank account number (without separators). - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - accountType: - description: 'The type of bank account. - - Only applicable to bank accounts held in the USA. - - The permitted values are: `checking`, `savings`. - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - bankAccountName: - description: The name of the bank account. - type: string - bankAccountReference: - x-addedInVersion: '5' - description: Merchant reference to the bank account. - type: string - bankAccountUUID: - description: 'The unique identifier (UUID) of the Bank Account. - - >If, during an account holder create or update request, this field is - left blank (but other fields provided), a new Bank Account will be created - with a procedurally-generated UUID. - - - >If, during an account holder create request, a UUID is provided, the - creation of the Bank Account will fail while the creation of the account - holder will continue. - - - >If, during an account holder update request, a UUID that is not correlated - with an existing Bank Account is provided, the update of the account holder - will fail. - - - >If, during an account holder update request, a UUID that is correlated - with an existing Bank Account is provided, the existing Bank Account will - be updated. - - ' - type: string - bankBicSwift: - description: 'The bank identifier code. - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - bankCity: - description: 'The city in which the bank branch is located. - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - bankCode: - description: 'The bank code of the banking institution with which the bank - account is registered. - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - bankName: - description: 'The name of the banking institution with which the bank account - is held. - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - branchCode: - description: 'The branch code of the branch under which the bank account - is registered. The value to be specified in this parameter depends on - the country of the bank account: - - * United States - Routing number - - * United Kingdom - Sort code - - * Germany - Bankleitzahl - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - checkCode: - description: 'The check code of the bank account. - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - countryCode: - description: 'The two-letter country code in which the bank account is registered. - - >The permitted country codes are defined in ISO-3166-1 alpha-2 (e.g. ''NL''). - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - currencyCode: - description: 'The currency in which the bank account deals. - - >The permitted currency codes are defined in ISO-4217 (e.g. ''EUR''). - - ' - type: string - iban: - description: 'The international bank account number. - - >The IBAN standard is defined in ISO-13616. - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - ownerCity: - description: 'The city of residence of the bank account owner. - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - ownerCountryCode: - description: 'The country code of the country of residence of the bank account - owner. - - >The permitted country codes are defined in ISO-3166-1 alpha-2 (e.g. ''NL''). - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - ownerDateOfBirth: - deprecated: true - description: 'The date of birth of the bank account owner. - - The date should be in ISO-8601 format yyyy-mm-dd (e.g. 2000-01-31).' - type: string - ownerHouseNumberOrName: - description: 'The house name or number of the residence of the bank account - owner. - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - ownerName: - description: 'The name of the bank account owner. - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - ownerNationality: - description: 'The country code of the country of nationality of the bank - account owner. - - >The permitted country codes are defined in ISO-3166-1 alpha-2 (e.g. ''NL''). - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - ownerPostalCode: - description: 'The postal code of the residence of the bank account owner. - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - ownerState: - description: 'The state of residence of the bank account owner. - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - ownerStreet: - description: 'The street name of the residence of the bank account owner. - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - primaryAccount: - description: If set to true, the bank account is a primary account. - type: boolean - taxId: - description: 'The tax ID number. - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - urlForVerification: - description: 'The URL to be used for bank account verification. - - This may be generated on bank account creation. - - - >Refer to [Required information](https://docs.adyen.com/classic-platforms/verification-process/required-information) - for details on field requirements.' - type: string - type: object - BusinessDetails: - additionalProperties: false - properties: - doingBusinessAs: - description: The registered name of the company (if it differs from the - legal name of the company). - type: string - legalBusinessName: - description: The legal name of the company. - type: string - listedUltimateParentCompany: - description: Information about the parent public company. Required if the - account holder is 100% owned by a publicly listed company. - items: - $ref: '#/components/schemas/UltimateParentCompany' - type: array - registrationNumber: - x-addedInVersion: '4' - description: The registration number of the company. - type: string - shareholders: - description: Array containing information about individuals associated with - the account holder either through ownership or control. For details about - how you can identify them, refer to [our verification guide](https://docs.adyen.com/classic-platforms/verification-process#identify-ubos). - items: - $ref: '#/components/schemas/ShareholderContact' - type: array - signatories: - description: 'Signatories associated with the company. - - Each array entry should represent one signatory.' - items: - $ref: '#/components/schemas/SignatoryContact' - type: array - stockExchange: - x-addedInVersion: '6' - description: Market Identifier Code (MIC). - type: string - stockNumber: - x-addedInVersion: '6' - description: International Securities Identification Number (ISIN). - type: string - stockTicker: - x-addedInVersion: '6' - description: Stock Ticker symbol. - type: string - taxId: - description: The tax ID of the company. - type: string - type: object - CloseAccountHolderRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the Account Holder to be closed. - type: string - required: - - accountHolderCode - type: object - CloseAccountHolderResponse: - additionalProperties: false - properties: - accountHolderStatus: - description: The new status of the Account Holder. - $ref: '#/components/schemas/AccountHolderStatus' - invalidFields: - x-addedInVersion: '5' - description: Contains field validation errors that would prevent requests - from being processed. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - type: object - CloseAccountRequest: - additionalProperties: false - properties: - accountCode: - description: The code of account to be closed. - type: string - required: - - accountCode - type: object - CloseAccountResponse: - additionalProperties: false - properties: - accountCode: - x-addedInVersion: '5' - description: The account code of the account that is closed. - type: string - invalidFields: - x-addedInVersion: '5' - description: Contains field validation errors that would prevent requests - from being processed. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - status: - x-addedInVersion: '2' - description: 'The new status of the account. - - >Permitted values: `Active`, `Inactive`, `Suspended`, `Closed`.' - enum: - - Active - - Closed - - Inactive - - Suspended - type: string - type: object - CloseStoresRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the account holder. - type: string - stores: - description: List of stores to be closed. - items: - type: string - type: array - required: - - accountHolderCode - - stores - type: object - CreateAccountHolderRequest: - additionalProperties: false - properties: - accountHolderCode: - description: 'Your unique identifier for the prospective account holder. - - The length must be between three (3) and fifty (50) characters long. Only - letters, digits, and hyphens (-) are allowed.' - type: string - accountHolderDetails: - description: The details of the prospective account holder. - $ref: '#/components/schemas/AccountHolderDetails' - createDefaultAccount: - description: 'If set to **true**, an account with the default options is - automatically created for the account holder. - - By default, this field is set to **true**.' - type: boolean - description: - x-addedInVersion: '4' - description: A description of the prospective account holder, maximum 256 - characters. You can use alphanumeric characters (A-Z, a-z, 0-9), white - spaces, and underscores `_`. - type: string - legalEntity: - description: 'The legal entity type of the account holder. This determines - the information that should be provided in the request. - - - Possible values: **Business**, **Individual**, or **NonProfit**. - - - * If set to **Business** or **NonProfit**, then `accountHolderDetails.businessDetails` - must be provided, with at least one entry in the `accountHolderDetails.businessDetails.shareholders` - list. - - - * If set to **Individual**, then `accountHolderDetails.individualDetails` - must be provided.' - enum: - - Business - - Individual - - NonProfit - - Partnership - - PublicCompany - type: string - primaryCurrency: - x-addedInVersion: '4' - deprecated: true - description: The three-character [ISO currency code](https://docs.adyen.com/development-resources/currency-codes), - with which the prospective account holder primarily deals. - type: string - processingTier: - x-addedInVersion: '3' - description: The starting [processing tier](https://docs.adyen.com/classic-platforms/onboarding-and-verification/precheck-kyc-information) - for the prospective account holder. - format: int32 - type: integer - verificationProfile: - x-addedInVersion: '6' - description: The identifier of the profile that applies to this entity. - type: string - required: - - accountHolderCode - - legalEntity - - accountHolderDetails - type: object - CreateAccountHolderResponse: - additionalProperties: false - properties: - accountCode: - description: The code of a new account created for the account holder. - type: string - accountHolderCode: - description: The code of the new account holder. - type: string - accountHolderDetails: - description: Details of the new account holder. - $ref: '#/components/schemas/AccountHolderDetails' - accountHolderStatus: - x-addedInVersion: '2' - description: The status of the new account holder. - $ref: '#/components/schemas/AccountHolderStatus' - description: - x-addedInVersion: '4' - description: The description of the new account holder. - type: string - invalidFields: - x-addedInVersion: '5' - description: A list of fields that caused the `/createAccountHolder` request - to fail. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - legalEntity: - x-addedInVersion: '4' - description: The type of legal entity of the new account holder. - enum: - - Business - - Individual - - NonProfit - - Partnership - - PublicCompany - type: string - primaryCurrency: - x-addedInVersion: '5' - deprecated: true - description: The three-character [ISO currency code](https://docs.adyen.com/development-resources/currency-codes), - with which the prospective account holder primarily deals. - type: string - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - verification: - x-addedInVersion: '2' - description: The details of KYC Verification of the account holder. - $ref: '#/components/schemas/KYCVerificationResult' - verificationProfile: - x-addedInVersion: '6' - description: The identifier of the profile that applies to this entity. - type: string - type: object - CreateAccountRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of Account Holder under which to create the account. - type: string - bankAccountUUID: - x-addedInVersion: '5' - description: The bankAccountUUID of the bank account held by the account - holder to couple the account with. Scheduled payouts in currencies matching - the currency of this bank account will be sent to this bank account. Payouts - in different currencies will be sent to a matching bank account of the - account holder. - type: string - description: - x-addedInVersion: '4' - description: A description of the account, maximum 256 characters. You can - use alphanumeric characters (A-Z, a-z, 0-9), white spaces, and underscores - `_`. - type: string - metadata: - x-addedInVersion: '5' - additionalProperties: - type: string - description: 'A set of key and value pairs for general use by the merchant. - - The keys do not have specific names and may be used for storing miscellaneous - data as desired. - - > Note that during an update of metadata, the omission of existing key-value - pairs will result in the deletion of those key-value pairs.' - type: object - payoutMethodCode: - x-addedInVersion: '5' - description: The payout method code held by the account holder to couple - the account with. Scheduled card payouts will be sent using this payout - method code. - type: string - payoutSchedule: - description: 'The payout schedule for the account. - - - Possible values: `DEFAULT`, `DAILY`, `DAILY_US`, `DAILY_EU`, `DAILY_AU`, - `DAILY_SG`, `WEEKLY`, `WEEKLY_ON_TUE_FRI_MIDNIGHT`, `BIWEEKLY_ON_1ST_AND_15TH_AT_MIDNIGHT`, - `MONTHLY`, `HOLD`. - - > `HOLD` prevents scheduled payouts, but you can still initiate payouts - manually.' - enum: - - BIWEEKLY_ON_1ST_AND_15TH_AT_MIDNIGHT - - DAILY - - DAILY_AU - - DAILY_EU - - DAILY_SG - - DAILY_US - - HOLD - - MONTHLY - - WEEKLY - - WEEKLY_MON_TO_FRI_AU - - WEEKLY_MON_TO_FRI_EU - - WEEKLY_MON_TO_FRI_US - - WEEKLY_ON_TUE_FRI_MIDNIGHT - - WEEKLY_SUN_TO_THU_AU - - WEEKLY_SUN_TO_THU_US - type: string - payoutScheduleReason: - description: 'The reason for the payout schedule choice. - - > This field is required when the `payoutSchedule` parameter is set to - `HOLD`.' - type: string - payoutSpeed: - x-addedInVersion: '5' - default: STANDARD - description: 'Speed at which payouts for this account are processed. - - - Possible values: `STANDARD` (default), `SAME_DAY`.' - enum: - - INSTANT - - SAME_DAY - - STANDARD - type: string - required: - - accountHolderCode - type: object - CreateAccountResponse: - additionalProperties: false - properties: - accountCode: - description: The code of the new account. - type: string - accountHolderCode: - description: The code of the account holder. - type: string - bankAccountUUID: - x-addedInVersion: '5' - description: The bankAccountUUID of the bank account held by the account - holder to couple the account with. Scheduled payouts in currencies matching - the currency of this bank account will be sent to this bank account. Payouts - in different currencies will be sent to a matching bank account of the - account holder. - type: string - description: - x-addedInVersion: '4' - description: The description of the account. - type: string - invalidFields: - x-addedInVersion: '5' - description: A list of fields that caused the `/createAccount` request to - fail. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - metadata: - x-addedInVersion: '5' - additionalProperties: - type: string - description: A set of key and value pairs containing metadata. - type: object - payoutMethodCode: - x-addedInVersion: '5' - description: The payout method code held by the account holder to couple - the account with. Scheduled card payouts will be sent using this payout - method code. - type: string - payoutSchedule: - description: The details of the payout schedule added to the account. - $ref: '#/components/schemas/PayoutScheduleResponse' - payoutSpeed: - x-addedInVersion: '5' - description: 'Speed with which payouts for this account are processed. Permitted - values: `STANDARD`, `SAME_DAY`.' - enum: - - INSTANT - - SAME_DAY - - STANDARD - type: string - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - status: - x-addedInVersion: '2' - description: 'The status of the account. - - >Permitted values: `Active`.' - enum: - - Active - - Closed - - Inactive - - Suspended - type: string - type: object - DeleteBankAccountRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the Account Holder from which to delete the Bank - Account(s). - type: string - bankAccountUUIDs: - description: The code(s) of the Bank Accounts to be deleted. - items: - type: string - type: array - required: - - accountHolderCode - - bankAccountUUIDs - type: object - DeleteLegalArrangementRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the account holder. - type: string - legalArrangements: - description: List of legal arrangements. - items: - $ref: '#/components/schemas/LegalArrangementRequest' - type: array - required: - - accountHolderCode - - legalArrangements - type: object - DeletePayoutMethodRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the account holder, from which to delete the payout - methods. - type: string - payoutMethodCodes: - description: The codes of the payout methods to be deleted. - items: - type: string - type: array - required: - - accountHolderCode - - payoutMethodCodes - type: object - DeleteShareholderRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the Account Holder from which to delete the Shareholders. - type: string - shareholderCodes: - description: The code(s) of the Shareholders to be deleted. - items: - type: string - type: array - required: - - accountHolderCode - - shareholderCodes - type: object - DeleteSignatoriesRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the account holder from which to delete the signatories. - type: string - signatoryCodes: - description: Array of codes of the signatories to be deleted. - items: - type: string - type: array - required: - - accountHolderCode - - signatoryCodes - type: object - DocumentDetail: - additionalProperties: false - properties: - accountHolderCode: - x-addedInVersion: '2' - description: The code of account holder, to which the document applies. - type: string - bankAccountUUID: - x-addedInVersion: '2' - description: 'The Adyen-generated [`bankAccountUUID`](https://docs.adyen.com/api-explorer/#/Account/latest/post/createAccountHolder__resParam_accountHolderDetails-bankAccountDetails-bankAccountUUID) - to which the document must be linked. Refer to [Bank account check](https://docs.adyen.com/classic-platforms/verification-checks/bank-account-check#uploading-a-bank-statement) - for details on when a document should be submitted. - - >Required if the `documentType` is **BANK_STATEMENT**, where a document - is being submitted in order to verify a bank account. - - ' - type: string - description: - description: Description of the document. - type: string - documentType: - description: 'The type of the document. Refer to [Verification checks](https://docs.adyen.com/classic-platforms/verification-checks) - for details on when each document type should be submitted and for the - accepted file formats. - - - Permitted values: - - * **BANK_STATEMENT**: A file containing a bank statement or other document - proving ownership of a specific bank account. - - * **COMPANY_REGISTRATION_SCREENING** (Supported from v5 and later): A - file containing a company registration document. - - * **CONSTITUTIONAL_DOCUMENT**: A file containing information about the - account holder''s legal arrangement. - - * **PASSPORT**: A file containing the identity page(s) of a passport. - - * **ID_CARD_FRONT**: A file containing only the front of the ID card. - In order for a document to be usable, both the **ID_CARD_FRONT** and **ID_CARD_BACK** - must be submitted. - - * **ID_CARD_BACK**: A file containing only the back of the ID card. In - order for a document to be usable, both the **ID_CARD_FRONT** and **ID_CARD_BACK** - must be submitted. - - * **DRIVING_LICENCE_FRONT**: A file containing only the front of the driving - licence. In order for a document to be usable, both the **DRIVING_LICENCE_FRONT** - and **DRIVING_LICENCE_BACK** must be submitted. - - * **DRIVING_LICENCE_BACK**: A file containing only the back of the driving - licence. In order for a document to be usable, both the **DRIVING_LICENCE_FRONT** - and **DRIVING_LICENCE_FRONT** must be submitted. - - ' - enum: - - BANK_STATEMENT - - BSN - - COMPANY_REGISTRATION_SCREENING - - CONSTITUTIONAL_DOCUMENT - - DRIVING_LICENCE - - DRIVING_LICENCE_BACK - - DRIVING_LICENCE_FRONT - - ID_CARD - - ID_CARD_BACK - - ID_CARD_FRONT - - PASSPORT - - PROOF_OF_RESIDENCY - - SSN - - SUPPORTING_DOCUMENTS - type: string - filename: - description: Filename of the document. - type: string - legalArrangementCode: - x-addedInVersion: '6' - description: The Adyen-generated [`legalArrangementCode`](https://docs.adyen.com/api-explorer/#/Account/latest/post/createAccountHolder__resParam_accountHolderDetails-legalArrangements-legalArrangementCode) - to which the document must be linked. - type: string - legalArrangementEntityCode: - x-addedInVersion: '6' - description: The Adyen-generated [`legalArrangementEntityCode`](https://docs.adyen.com/api-explorer/#/Account/v6/post/createAccountHolder__resParam_accountHolderDetails-legalArrangements-legalArrangementEntities-legalArrangementEntityCode) to - which the document must be linked. - type: string - shareholderCode: - x-addedInVersion: '2' - description: 'The Adyen-generated [`shareholderCode`](https://docs.adyen.com/api-explorer/#/Account/latest/post/createAccountHolder__resParam_accountHolderDetails-businessDetails-shareholders-shareholderCode) - to which the document must be linked. Refer to [Verification checks](https://docs.adyen.com/classic-platforms/verification-checks) - for details on when a document should be submitted. - - >Required if the account holder has a `legalEntity` of type **Business** - and the `documentType` is either **PASSPORT**, **ID_CARD_FRONT**, **ID_CARD_BACK**, - **DRIVING_LICENCE_FRONT**, or **DRIVING_LICENCE_BACK**. ' - type: string - signatoryCode: - description: The Adyen-generated [`signatoryCode`](https://docs.adyen.com/api-explorer/#/Account/v6/post/createAccountHolder__resParam_accountHolderDetails-businessDetails-signatories-signatoryCode) - to which the document must be linked. - type: string - required: - - documentType - type: object - ErrorFieldType: - additionalProperties: false - properties: - errorCode: - description: The validation error code. - format: int32 - type: integer - errorDescription: - description: A description of the validation error. - type: string - fieldType: - description: The type of error field. - $ref: '#/components/schemas/FieldType' - type: object - FieldType: - additionalProperties: false - properties: - field: - description: The full name of the property. - type: string - fieldName: - description: The type of the field. - enum: - - accountCode - - accountHolderCode - - accountHolderDetails - - accountNumber - - accountStateType - - accountStatus - - accountType - - address - - balanceAccount - - balanceAccountActive - - balanceAccountCode - - balanceAccountId - - bankAccount - - bankAccountCode - - bankAccountName - - bankAccountUUID - - bankBicSwift - - bankCity - - bankCode - - bankName - - bankStatement - - branchCode - - businessContact - - cardToken - - checkCode - - city - - companyRegistration - - constitutionalDocument - - controller - - country - - countryCode - - currency - - currencyCode - - dateOfBirth - - description - - destinationAccountCode - - document - - documentContent - - documentExpirationDate - - documentIssuerCountry - - documentIssuerState - - documentName - - documentNumber - - documentType - - doingBusinessAs - - drivingLicence - - drivingLicenceBack - - drivingLicenceFront - - drivingLicense - - email - - firstName - - formType - - fullPhoneNumber - - gender - - hopWebserviceUser - - houseNumberOrName - - iban - - idCard - - idCardBack - - idCardFront - - idNumber - - identityDocument - - individualDetails - - infix - - jobTitle - - lastName - - lastReviewDate - - legalArrangement - - legalArrangementCode - - legalArrangementEntity - - legalArrangementEntityCode - - legalArrangementLegalForm - - legalArrangementMember - - legalArrangementMembers - - legalArrangementName - - legalArrangementReference - - legalArrangementRegistrationNumber - - legalArrangementTaxNumber - - legalArrangementType - - legalBusinessName - - legalEntity - - legalEntityType - - linkedViasVirtualAccount - - logo - - merchantAccount - - merchantCategoryCode - - merchantHouseNumber - - merchantReference - - microDeposit - - name - - nationality - - originalReference - - ownerCity - - ownerCountryCode - - ownerDateOfBirth - - ownerHouseNumberOrName - - ownerName - - ownerPostalCode - - ownerState - - ownerStreet - - passport - - passportNumber - - payoutMethod - - payoutMethodCode - - payoutSchedule - - pciSelfAssessment - - personalData - - phoneCountryCode - - phoneNumber - - postalCode - - primaryCurrency - - reason - - registrationNumber - - returnUrl - - schedule - - shareholder - - shareholderCode - - shareholderCodeAndSignatoryCode - - shareholderCodeOrSignatoryCode - - shareholderType - - shareholderTypes - - shopperInteraction - - signatory - - signatoryCode - - socialSecurityNumber - - sourceAccountCode - - splitAccount - - splitConfigurationUUID - - splitCurrency - - splitValue - - splits - - stateOrProvince - - status - - stockExchange - - stockNumber - - stockTicker - - store - - storeDetail - - storeName - - storeReference - - street - - taxId - - tier - - tierNumber - - transferCode - - ultimateParentCompany - - ultimateParentCompanyAddressDetails - - ultimateParentCompanyAddressDetailsCountry - - ultimateParentCompanyBusinessDetails - - ultimateParentCompanyBusinessDetailsLegalBusinessName - - ultimateParentCompanyBusinessDetailsRegistrationNumber - - ultimateParentCompanyCode - - ultimateParentCompanyStockExchange - - ultimateParentCompanyStockNumber - - ultimateParentCompanyStockNumberOrStockTicker - - ultimateParentCompanyStockTicker - - unknown - - value - - verificationType - - virtualAccount - - visaNumber - - webAddress - - year - type: string - shareholderCode: - description: The code of the shareholder that the field belongs to. If empty, - the field belongs to an account holder. - type: string - type: object - GenericResponse: - additionalProperties: false - properties: - invalidFields: - x-addedInVersion: '5' - description: Contains field validation errors that would prevent requests - from being processed. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - type: object - GetAccountHolderRequest: - additionalProperties: false - properties: - accountCode: - description: 'The code of the account of which to retrieve the details. - - > Required if no `accountHolderCode` is provided.' - type: string - accountHolderCode: - description: 'The code of the account holder of which to retrieve the details. - - > Required if no `accountCode` is provided.' - type: string - showDetails: - x-addedInVersion: '4' - description: True if the request should return the account holder details - type: boolean - type: object - GetAccountHolderResponse: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the account holder. - type: string - accountHolderDetails: - description: Details of the account holder. - $ref: '#/components/schemas/AccountHolderDetails' - accountHolderStatus: - x-addedInVersion: '2' - description: The status of the account holder. - $ref: '#/components/schemas/AccountHolderStatus' - accounts: - description: A list of the accounts under the account holder. - items: - $ref: '#/components/schemas/Account' - type: array - description: - x-addedInVersion: '4' - description: The description of the account holder. - type: string - invalidFields: - x-addedInVersion: '5' - description: Contains field validation errors that would prevent requests - from being processed. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - legalEntity: - description: The legal entity of the account holder. - enum: - - Business - - Individual - - NonProfit - - Partnership - - PublicCompany - type: string - migrationData: - x-addedInVersion: '5' - description: Details of the account holder migrated to the balance platform. - $ref: '#/components/schemas/MigrationData' - primaryCurrency: - x-addedInVersion: '4' - description: The three-character [ISO currency code](https://docs.adyen.com/development-resources/currency-codes), - with which the prospective account holder primarily deals. - type: string - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - systemUpToDateTime: - x-addedInVersion: '5' - description: The time that shows how up to date is the information in the - response. - format: date-time - type: string - verification: - x-addedInVersion: '2' - description: The details of KYC Verification of the account holder. - $ref: '#/components/schemas/KYCVerificationResult' - verificationProfile: - x-addedInVersion: '6' - description: The identifier of the profile that applies to this entity. - type: string - type: object - GetAccountHolderStatusResponse: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the Account Holder. - type: string - accountHolderStatus: - x-addedInVersion: '2' - description: The status of the Account Holder. - $ref: '#/components/schemas/AccountHolderStatus' - invalidFields: - x-addedInVersion: '5' - description: Contains field validation errors that would prevent requests - from being processed. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - type: object - GetTaxFormRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The account holder code you provided when you created the account - holder. - type: string - formType: - description: Type of the requested tax form. For example, 1099-K. - type: string - year: - description: Applicable tax year in the YYYY format. - format: int32 - type: integer - required: - - accountHolderCode - - formType - - year - type: object - GetTaxFormResponse: - additionalProperties: false - properties: - content: - description: The content of the tax form in the Base64 binary format. - format: byte - type: string - contentType: - description: The content type of the tax form. - type: string - invalidFields: - x-addedInVersion: '5' - description: Contains field validation errors that would prevent requests - from being processed. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - type: object - GetUploadedDocumentsRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the Account Holder for which to retrieve the documents. - type: string - bankAccountUUID: - x-addedInVersion: '2' - description: The code of the Bank Account for which to retrieve the documents. - type: string - shareholderCode: - description: The code of the Shareholder for which to retrieve the documents. - type: string - required: - - accountHolderCode - type: object - GetUploadedDocumentsResponse: - additionalProperties: false - properties: - documentDetails: - description: A list of the documents and their details. - items: - $ref: '#/components/schemas/DocumentDetail' - type: array - invalidFields: - x-addedInVersion: '5' - description: Contains field validation errors that would prevent requests - from being processed. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - type: object - IndividualDetails: - additionalProperties: false - properties: - name: - description: "The name of the individual.\n>Make sure your account holder\ - \ registers using the name shown on their Photo ID. \n Maximum length:\ - \ 80 characters \n Cannot contain numbers. /n Cannot be empty." - $ref: '#/components/schemas/ViasName' - personalData: - description: Personal information of the individual. - $ref: '#/components/schemas/ViasPersonalData' - type: object - KYCCheckResult: - additionalProperties: false - properties: - checks: - description: A list of the checks and their statuses. - items: - $ref: '#/components/schemas/KYCCheckStatusData' - type: array - type: object - KYCCheckStatusData: - additionalProperties: false - properties: - requiredFields: - description: A list of the fields required for execution of the check. - items: - type: string - type: array - status: - description: 'The status of the check. - - - Possible values: **AWAITING_DATA** , **DATA_PROVIDED**, **FAILED**, **INVALID_DATA**, - **PASSED**, **PENDING**, **RETRY_LIMIT_REACHED**.' - enum: - - AWAITING_DATA - - DATA_PROVIDED - - FAILED - - INVALID_DATA - - PASSED - - PENDING - - PENDING_REVIEW - - RETRY_LIMIT_REACHED - - UNCHECKED - type: string - summary: - description: A summary of the execution of the check. - $ref: '#/components/schemas/KYCCheckSummary' - type: - description: "The type of check.\n\nPossible values:\n\n * **BANK_ACCOUNT_VERIFICATION**:\ - \ Used in v5 and earlier. Replaced by **PAYOUT_METHOD_VERIFICATION** in\ - \ v6 and later.\n\n * **COMPANY_VERIFICATION**\n\n * **CARD_VERIFICATION**\n\ - \n* **IDENTITY_VERIFICATION**\n\n* **LEGAL_ARRANGEMENT_VERIFICATION**\n\ - \n* **NONPROFIT_VERIFICATION**\n\n * **PASSPORT_VERIFICATION**\n\n* **PAYOUT_METHOD_VERIFICATION**:\ - \ Used in v6 and later.\n\n* **PCI_VERIFICATION**" - enum: - - BANK_ACCOUNT_VERIFICATION - - CARD_VERIFICATION - - COMPANY_VERIFICATION - - IDENTITY_VERIFICATION - - LEGAL_ARRANGEMENT_VERIFICATION - - NONPROFIT_VERIFICATION - - PASSPORT_VERIFICATION - - PAYOUT_METHOD_VERIFICATION - - PCI_VERIFICATION - type: string - required: - - type - - status - type: object - KYCCheckSummary: - additionalProperties: false - properties: - kycCheckCode: - x-addedInVersion: '5' - description: The code of the check. For possible values, refer to [Verification - codes](https://docs.adyen.com/classic-platforms/verification-process/verification-codes). - format: int32 - type: integer - kycCheckDescription: - x-addedInVersion: '5' - description: A description of the check. - type: string - type: object - KYCLegalArrangementCheckResult: - additionalProperties: false - properties: - checks: - description: A list of the checks and their statuses. - items: - $ref: '#/components/schemas/KYCCheckStatusData' - type: array - legalArrangementCode: - description: The unique ID of the legal arrangement to which the check applies. - type: string - type: object - KYCLegalArrangementEntityCheckResult: - additionalProperties: false - properties: - checks: - description: A list of the checks and their statuses. - items: - $ref: '#/components/schemas/KYCCheckStatusData' - type: array - legalArrangementCode: - description: The unique ID of the legal arrangement to which the entity - belongs. - type: string - legalArrangementEntityCode: - description: The unique ID of the legal arrangement entity to which the - check applies. - type: string - type: object - KYCPayoutMethodCheckResult: - additionalProperties: false - properties: - checks: - description: A list of the checks and their statuses. - items: - $ref: '#/components/schemas/KYCCheckStatusData' - type: array - payoutMethodCode: - description: The unique ID of the payoput method to which the check applies. - type: string - type: object - KYCShareholderCheckResult: - additionalProperties: false - properties: - checks: - description: A list of the checks and their statuses. - items: - $ref: '#/components/schemas/KYCCheckStatusData' - type: array - legalArrangementCode: - x-addedInVersion: '6' - description: The unique ID of the legal arrangement to which the shareholder - belongs, if applicable. - type: string - legalArrangementEntityCode: - x-addedInVersion: '6' - description: The unique ID of the legal arrangement entity to which the - shareholder belongs, if applicable. - type: string - shareholderCode: - description: The code of the shareholder to which the check applies. - type: string - type: object - KYCSignatoryCheckResult: - additionalProperties: false - properties: - checks: - description: A list of the checks and their statuses. - items: - $ref: '#/components/schemas/KYCCheckStatusData' - type: array - signatoryCode: - description: The code of the signatory to which the check applies. - type: string - type: object - KYCUltimateParentCompanyCheckResult: - additionalProperties: false - properties: - checks: - description: A list of the checks and their statuses. - items: - $ref: '#/components/schemas/KYCCheckStatusData' - type: array - ultimateParentCompanyCode: - x-addedInVersion: '6' - description: The code of the Ultimate Parent Company to which the check - applies. - type: string - type: object - KYCVerificationResult: - additionalProperties: false - properties: - accountHolder: - description: The results of the checks on the account holder. - $ref: '#/components/schemas/KYCCheckResult' - legalArrangements: - x-addedInVersion: '6' - description: The results of the checks on the legal arrangements. - items: - $ref: '#/components/schemas/KYCLegalArrangementCheckResult' - type: array - legalArrangementsEntities: - x-addedInVersion: '6' - description: The results of the checks on the legal arrangement entities. - items: - $ref: '#/components/schemas/KYCLegalArrangementEntityCheckResult' - type: array - payoutMethods: - x-addedInVersion: '6' - description: The results of the checks on the payout methods. - items: - $ref: '#/components/schemas/KYCPayoutMethodCheckResult' - type: array - shareholders: - description: The results of the checks on the shareholders. - items: - $ref: '#/components/schemas/KYCShareholderCheckResult' - type: array - signatories: - description: The results of the checks on the signatories. - items: - $ref: '#/components/schemas/KYCSignatoryCheckResult' - type: array - ultimateParentCompany: - x-addedInVersion: '6' - description: The result of the check on the Ultimate Parent Company. - items: - $ref: '#/components/schemas/KYCUltimateParentCompanyCheckResult' - type: array - type: object - LegalArrangementDetail: - additionalProperties: false - properties: - address: - description: The address of the legal arrangement. - $ref: '#/components/schemas/ViasAddress' - legalArrangementCode: - description: 'Adyen-generated unique alphanumeric identifier (UUID) for - the entry, returned in the response when you create a legal arrangement. - - Use only when updating an account holder. If you include this field when - creating an account holder, the request will fail.' - type: string - legalArrangementEntities: - description: An array containing information about other entities that are - part of the legal arrangement. - items: - $ref: '#/components/schemas/LegalArrangementEntityDetail' - type: array - legalArrangementReference: - description: Your reference for the legal arrangement. Must be between 3 - to 128 characters. - type: string - legalForm: - description: 'The form of legal arrangement. Required if `type` is **Trust** - or **Partnership**. - - - The possible values depend on the `type`. - - - - For `type` **Trust**: **CashManagementTrust**, **CorporateUnitTrust**, - **DeceasedEstate**, **DiscretionaryInvestmentTrust**, **DiscretionaryServicesManagementTrust**, - **DiscretionaryTradingTrust**, **FirstHomeSaverAccountsTrust**, **FixedTrust**, - **FixedUnitTrust**, **HybridTrust**, **ListedPublicUnitTrust**, **OtherTrust**, - **PooledSuperannuationTrust**, **PublicTradingTrust**, or **UnlistedPublicUnitTrust**. - - - - For `type` **Partnership**: **LimitedPartnership**, **FamilyPartnership**, - or **OtherPartnership**' - enum: - - CashManagementTrust - - CorporateUnitTrust - - DeceasedEstate - - DiscretionaryInvestmentTrust - - DiscretionaryServicesManagementTrust - - DiscretionaryTradingTrust - - FirstHomeSaverAccountsTrust - - FixedTrust - - FixedUnitTrust - - HybridTrust - - ListedPublicUnitTrust - - OtherTrust - - PooledSuperannuationTrust - - PublicTradingTrust - - UnlistedPublicUnitTrust - - LimitedPartnership - - FamilyPartnership - - OtherPartnership - type: string - name: - description: 'The legal name of the legal arrangement. Minimum length: 3 - characters.' - type: string - registrationNumber: - description: The registration number of the legal arrangement. - type: string - taxNumber: - description: The tax identification number of the legal arrangement. - type: string - type: - description: "The [type of legal arrangement](https://docs.adyen.com/classic-platforms/verification-process/legal-arrangements#types-of-legal-arrangements).\n\ - \nPossible values:\n\n- **Association** \n\n- **Partnership** \n\n- **SoleProprietorship**\ - \ \n\n- **Trust** \n\n" - enum: - - Association - - Partnership - - SoleProprietorship - - Trust - type: string - required: - - type - - name - - address - type: object - LegalArrangementEntityDetail: - additionalProperties: false - properties: - address: - description: The address of the entity. - $ref: '#/components/schemas/ViasAddress' - businessDetails: - description: Required when creating an entity with `legalEntityType` **Business**, - **NonProfit**, **PublicCompany**, or **Partnership**. - $ref: '#/components/schemas/BusinessDetails' - email: - description: The e-mail address of the entity. - type: string - fullPhoneNumber: - description: 'The phone number of the contact provided as a single string. It - will be handled as a landline phone. - - **Examples:** "0031 6 11 22 33 44", "+316/1122-3344", "(0031) 611223344"' - type: string - individualDetails: - description: Required when creating an entity with `legalEntityType` **Individual**. - $ref: '#/components/schemas/IndividualDetails' - legalArrangementEntityCode: - description: 'Adyen-generated unique alphanumeric identifier (UUID) for - the entry, returned in the response when you create a legal arrangement - entity. - - Use only when updating an account holder. If you include this field when - creating an account holder, the request will fail.' - type: string - legalArrangementEntityReference: - description: Your reference for the legal arrangement entity. - type: string - legalArrangementMembers: - description: 'An array containing the roles of the entity in the legal arrangement. - - - The possible values depend on the legal arrangement `type`. - - - - For `type` **Association**: **ControllingPerson** and **Shareholder**. - - - - For `type` **Partnership**: **Partner** and **Shareholder**. - - - - For `type` **Trust**: **Trustee**, **Settlor**, **Protector**, **Beneficiary**, and - **Shareholder**. - - - ' - items: - enum: - - Beneficiary - - ControllingPerson - - Partner - - Protector - - Settlor - - Shareholder - - Trustee - type: string - type: array - legalEntityType: - description: 'The legal entity type. - - - Possible values: **Business**, **Individual**, **NonProfit**, **PublicCompany**, - or **Partnership**. ' - enum: - - Business - - Individual - - NonProfit - - Partnership - - PublicCompany - type: string - phoneNumber: - description: The phone number of the entity. - $ref: '#/components/schemas/ViasPhoneNumber' - webAddress: - description: The URL of the website of the contact. - type: string - type: object - LegalArrangementRequest: - additionalProperties: false - properties: - legalArrangementCode: - description: The code of the legal arrangement to be deleted. If you also - send `legalArrangementEntityCodes`, only the entities listed will be deleted. - type: string - legalArrangementEntityCodes: - description: List of legal arrangement entities to be deleted. - items: - type: string - type: array - required: - - legalArrangementCode - type: object - MigratedAccounts: - additionalProperties: false - properties: - accountCode: - description: The unique identifier of the account of the migrated account - holder in the classic integration. - type: string - balanceAccountId: - description: The unique identifier of the account of the migrated account - holder in the balance platform. - type: string - type: object - MigratedShareholders: - additionalProperties: false - properties: - legalEntityCode: - description: The unique identifier of the legal entity of that shareholder - in the balance platform. - type: string - shareholderCode: - description: The unique identifier of the account of the migrated shareholder - in the classic integration. - type: string - type: object - MigratedStores: - additionalProperties: false - properties: - businessLineId: - description: The unique identifier of the business line associated with - the migrated account holder in the balance platform. - type: string - storeCode: - description: The unique identifier of the store associated with the migrated - account holder in the classic integration. - type: string - storeId: - description: The unique identifier of the store associated with the migrated - account holder in the balance platform. - type: string - storeReference: - description: Your reference for the store in the classic integration. The - [Customer Area](https://ca-test.adyen.com/) uses this value for the store - description. - type: string - type: object - MigrationData: - additionalProperties: false - properties: - accountHolderId: - description: The unique identifier of the account holder in the balance - platform. - type: string - balancePlatform: - description: The unique identifier of the balance platfrom to which the - account holder was migrated. - type: string - migrated: - description: Set to **true** if the account holder has been migrated. - type: boolean - migratedAccounts: - description: Contains the mapping of virtual account codes (classic integration) - to the balance account codes (balance platform) associated with the migrated - account holder. - items: - $ref: '#/components/schemas/MigratedAccounts' - type: array - migratedShareholders: - description: Contains the mapping of shareholders associated with the migrated - legal entities. - items: - $ref: '#/components/schemas/MigratedShareholders' - type: array - migratedStores: - description: Contains the mapping of business lines and stores associated - with the migrated account holder. - items: - $ref: '#/components/schemas/MigratedStores' - type: array - migrationDate: - description: The date when account holder was migrated. - format: date-time - type: string - type: object - PayoutMethod: - additionalProperties: false - properties: - merchantAccount: - description: The [`merchantAccount`](https://docs.adyen.com/api-explorer/#/CheckoutService/latest/post/payments__reqParam_merchantAccount) - you used in the `/payments` request when you [saved the account holder's - card details](https://docs.adyen.com/classic-platforms/payouts/manual-payout/payout-to-cards#check-and-store). - type: string - payoutMethodCode: - description: Adyen-generated unique alphanumeric identifier (UUID) for the - payout method, returned in the response when you create a payout method. - Required when updating an existing payout method in an `/updateAccountHolder` - request. - type: string - payoutMethodReference: - description: Your reference for the payout method. - type: string - recurringDetailReference: - description: The [`recurringDetailReference`](https://docs.adyen.com/api-explorer/#/CheckoutService/latest/post/payments__resParam_additionalData-ResponseAdditionalDataCommon-recurring-recurringDetailReference) returned - in the `/payments` response when you [saved the account holder's card - details](https://docs.adyen.com/classic-platforms/payouts/manual-payout/payout-to-cards#check-and-store). - type: string - shopperReference: - description: The [`shopperReference`](https://docs.adyen.com/api-explorer/#/CheckoutService/latest/post/payments__reqParam_shopperReference) - you sent in the `/payments` request when you [saved the account holder's - card details](https://docs.adyen.com/classic-platforms/payouts/manual-payout/payout-to-cards#check-and-store). - type: string - required: - - merchantAccount - - shopperReference - - recurringDetailReference - type: object - PayoutScheduleResponse: - additionalProperties: false - properties: - nextScheduledPayout: - description: The date of the next scheduled payout. - format: date-time - type: string - schedule: - description: 'The payout schedule for the account. - - - Possible values: `DEFAULT`, `DAILY`, `DAILY_US`, `DAILY_EU`, `DAILY_AU`, - `DAILY_SG`, `WEEKLY`, `WEEKLY_ON_TUE_FRI_MIDNIGHT`, `BIWEEKLY_ON_1ST_AND_15TH_AT_MIDNIGHT`, - `MONTHLY`, `HOLD`. - - > `HOLD` prevents scheduled payouts, but you can still initiate payouts - manually.' - enum: - - BIWEEKLY_ON_1ST_AND_15TH_AT_MIDNIGHT - - DAILY - - DAILY_AU - - DAILY_EU - - DAILY_SG - - DAILY_US - - HOLD - - MONTHLY - - WEEKLY - - WEEKLY_MON_TO_FRI_AU - - WEEKLY_MON_TO_FRI_EU - - WEEKLY_MON_TO_FRI_US - - WEEKLY_ON_TUE_FRI_MIDNIGHT - - WEEKLY_SUN_TO_THU_AU - - WEEKLY_SUN_TO_THU_US - type: string - type: object - PerformVerificationRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the account holder to verify. - type: string - accountStateType: - description: 'The state required for the account holder. - - > Permitted values: `Processing`, `Payout`.' - enum: - - LimitedPayout - - LimitedProcessing - - LimitlessPayout - - LimitlessProcessing - - Payout - - Processing - type: string - tier: - description: The tier required for the account holder. - format: int32 - type: integer - required: - - accountHolderCode - - accountStateType - - tier - type: object - PersonalDocumentData: - additionalProperties: false - properties: - expirationDate: - description: "The expiry date of the document, \n in ISO-8601 YYYY-MM-DD\ - \ format. For example, **2000-01-31**." - type: string - issuerCountry: - description: "The country where the document was issued, in the two-character\ - \ \n[ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2)\ - \ format. For example, **NL**." - maxLength: 2 - minLength: 2 - type: string - issuerState: - description: The state where the document was issued (if applicable). - type: string - number: - description: The number in the document. - type: string - type: - description: 'The type of the document. Possible values: **ID**, **DRIVINGLICENSE**, - **PASSPORT**, **SOCIALSECURITY**, **VISA**. - - - To delete an existing entry for a document `type`, send only the `type` - field in your request. ' - enum: - - DRIVINGLICENSE - - ID - - PASSPORT - - SOCIALSECURITY - - VISA - type: string - required: - - type - type: object - ServiceError: - additionalProperties: false - properties: - errorCode: - description: The error code mapped to the error message. - type: string - errorType: - description: The category of the error. - type: string - message: - description: A short explanation of the issue. - type: string - pspReference: - description: The PSP reference of the payment. - type: string - status: - description: The HTTP response status. - format: int32 - type: integer - type: object - ShareholderContact: - additionalProperties: false - properties: - address: - description: The address of the person. - $ref: '#/components/schemas/ViasAddress' - email: - description: The e-mail address of the person. - type: string - fullPhoneNumber: - description: 'The phone number of the person provided as a single string. It - will be handled as a landline phone. - - Examples: "0031 6 11 22 33 44", "+316/1122-3344", "(0031) 611223344"' - type: string - jobTitle: - description: 'Job title of the person. Required when the `shareholderType` - is **Controller**. - - - Example values: **Chief Executive Officer**, **Chief Financial Officer**, - **Chief Operating Officer**, **President**, **Vice President**, **Executive - President**, **Managing Member**, **Partner**, **Treasurer**, **Director**, - or **Other**.' - type: string - name: - description: The name of the person. - $ref: '#/components/schemas/ViasName' - personalData: - description: Contains information about the person. - $ref: '#/components/schemas/ViasPersonalData' - phoneNumber: - description: The phone number of the person. - $ref: '#/components/schemas/ViasPhoneNumber' - shareholderCode: - description: 'The unique identifier (UUID) of the shareholder entry. - - >**If, during an Account Holder create or update request, this field is - left blank (but other fields provided), a new Shareholder will be created - with a procedurally-generated UUID.** - - - >**If, during an Account Holder create request, a UUID is provided, the - creation of Account Holder will fail with a validation Error..** - - - >**If, during an Account Holder update request, a UUID that is not correlated - with an existing Shareholder is provided, the update of the Shareholder - will fail.** - - - >**If, during an Account Holder update request, a UUID that is correlated - with an existing Shareholder is provided, the existing Shareholder will - be updated.** - - ' - type: string - shareholderReference: - x-addedInVersion: '5' - description: Your reference for the shareholder entry. - type: string - shareholderType: - description: "Specifies how the person is associated with the account holder.\ - \ \n\nPossible values: \n\n* **Owner**: Individuals who directly or indirectly\ - \ own 25% or more of a company.\n\n* **Controller**: Individuals who are\ - \ members of senior management staff responsible for managing a company\ - \ or organization." - enum: - - Controller - - Owner - - Signatory - type: string - webAddress: - description: The URL of the person's website. - type: string - type: object - SignatoryContact: - additionalProperties: false - properties: - address: - description: The address of the person. - $ref: '#/components/schemas/ViasAddress' - email: - description: The e-mail address of the person. - type: string - fullPhoneNumber: - description: 'The phone number of the person provided as a single string. It - will be handled as a landline phone. - - Examples: "0031 6 11 22 33 44", "+316/1122-3344", "(0031) 611223344"' - type: string - jobTitle: - description: 'Job title of the signatory. - - - Example values: **Chief Executive Officer**, **Chief Financial Officer**, - **Chief Operating Officer**, **President**, **Vice President**, **Executive - President**, **Managing Member**, **Partner**, **Treasurer**, **Director**, - or **Other**.' - type: string - name: - description: The name of the person. - $ref: '#/components/schemas/ViasName' - personalData: - description: Contains information about the person. - $ref: '#/components/schemas/ViasPersonalData' - phoneNumber: - description: The phone number of the person. - $ref: '#/components/schemas/ViasPhoneNumber' - signatoryCode: - description: 'The unique identifier (UUID) of the signatory. - - >**If, during an Account Holder create or update request, this field is - left blank (but other fields provided), a new Signatory will be created - with a procedurally-generated UUID.** - - - >**If, during an Account Holder create request, a UUID is provided, the - creation of the Signatory will fail while the creation of the Account - Holder will continue.** - - - >**If, during an Account Holder update request, a UUID that is not correlated - with an existing Signatory is provided, the update of the Signatory will - fail.** - - - >**If, during an Account Holder update request, a UUID that is correlated - with an existing Signatory is provided, the existing Signatory will be - updated.** - - ' - type: string - signatoryReference: - description: Your reference for the signatory. - type: string - webAddress: - description: The URL of the person's website. - type: string - type: object - StoreDetail: - additionalProperties: false - properties: - address: - description: The address of the physical store where the account holder - will process payments from. - $ref: '#/components/schemas/ViasAddress' - fullPhoneNumber: - description: 'The phone number of the store provided as a single string. It - will be handled as a landline phone. - - - Examples: "0031 6 11 22 33 44", "+316/1122-3344", "(0031) 611223344"' - type: string - logo: - x-addedInVersion: '5' - description: Store logo for payment method setup. - type: string - merchantAccount: - description: The merchant account to which the store belongs. - type: string - merchantCategoryCode: - description: The merchant category code (MCC) that classifies the business - of the account holder. - type: string - merchantHouseNumber: - x-addedInVersion: '5' - description: Merchant house number for payment method setup. - type: string - phoneNumber: - description: The phone number of the store. - $ref: '#/components/schemas/ViasPhoneNumber' - shopperInteraction: - x-addedInVersion: '5' - description: 'The sales channel. Possible values: **Ecommerce**, **POS**.' - enum: - - Ecommerce - - POS - type: string - splitConfigurationUUID: - x-addedInVersion: '5' - description: The unique reference for the split configuration, returned - when you configure splits in your Customer Area. When this is provided, - the `virtualAccount` is also required. Adyen uses the configuration and - the `virtualAccount` to split funds between accounts in your platform. - type: string - status: - description: 'The status of the store. Possible values: **Pending**, **Active**, - **Inactive**, **InactiveWithModifications**, **Closed**.' - enum: - - Active - - Closed - - Inactive - - InactiveWithModifications - - Pending - type: string - store: - description: Adyen-generated unique alphanumeric identifier (UUID) for the - store, returned in the response when you create a store. Required when - updating an existing store in an `/updateAccountHolder` request. - type: string - storeName: - description: "The name of the account holder's store. This value is shown\ - \ in shopper statements.\n\n * Length: Between 3 to 22 characters \n\n\ - \ * The following characters are *not* supported: **:;}{$#@!|<>%^*+=\\\ - \\**\n\n**Note:** storeName does not appear in American Express shopper\ - \ statements by default. Contact Adyen Support to enable this for American\ - \ Express." - type: string - storeReference: - description: "Your unique identifier for the store. The Customer Area also\ - \ uses this value for the store description.\n\n * Length: Between 3 to\ - \ 128 characters\n\n* The following characters are *not* supported: **:;}{$#@!|<>%^*+=\\\ - \\**" - type: string - virtualAccount: - x-addedInVersion: '5' - description: The account holder's `accountCode` where the split amount will - be sent. Required when you provide the `splitConfigurationUUID`. - type: string - webAddress: - x-addedInVersion: '5' - description: URL of the ecommerce store. - type: string - required: - - merchantCategoryCode - - address - - merchantAccount - type: object - SuspendAccountHolderRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the account holder to be suspended. - type: string - required: - - accountHolderCode - type: object - SuspendAccountHolderResponse: - additionalProperties: false - properties: - accountHolderStatus: - description: The new status of the Account Holder. - $ref: '#/components/schemas/AccountHolderStatus' - invalidFields: - x-addedInVersion: '5' - description: Contains field validation errors that would prevent requests - from being processed. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - type: object - UltimateParentCompany: - additionalProperties: false - properties: - address: - description: Address of the ultimate parent company. - $ref: '#/components/schemas/ViasAddress' - businessDetails: - description: Details about the ultimate parent company's business. - $ref: '#/components/schemas/UltimateParentCompanyBusinessDetails' - ultimateParentCompanyCode: - description: Adyen-generated unique alphanumeric identifier (UUID) for the - entry, returned in the response when you create an ultimate parent company. - Required when updating an existing entry in an `/updateAccountHolder` - request. - type: string - type: object - UltimateParentCompanyBusinessDetails: - additionalProperties: false - properties: - legalBusinessName: - description: The legal name of the company. - type: string - registrationNumber: - description: The registration number of the company. - type: string - stockExchange: - description: Market Identifier Code (MIC). - type: string - stockNumber: - description: International Securities Identification Number (ISIN). - type: string - stockTicker: - description: Stock Ticker symbol. - type: string - type: object - UnSuspendAccountHolderRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the account holder to be reinstated. - type: string - required: - - accountHolderCode - type: object - UnSuspendAccountHolderResponse: - additionalProperties: false - properties: - accountHolderStatus: - description: The new status of the Account Holder. - $ref: '#/components/schemas/AccountHolderStatus' - invalidFields: - x-addedInVersion: '5' - description: Contains field validation errors that would prevent requests - from being processed. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - type: object - UpdateAccountHolderRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the Account Holder to be updated. - type: string - accountHolderDetails: - description: 'The details to which the Account Holder should be updated. - - - Required if a processingTier is not provided.' - $ref: '#/components/schemas/AccountHolderDetails' - description: - x-addedInVersion: '4' - description: A description of the account holder, maximum 256 characters. - You can use alphanumeric characters (A-Z, a-z, 0-9), white spaces, and - underscores `_`. - type: string - legalEntity: - x-addedInVersion: '5' - description: 'The legal entity type of the account holder. This determines - the information that should be provided in the request. - - - Possible values: **Business**, **Individual**, or **NonProfit**. - - - * If set to **Business** or **NonProfit**, then `accountHolderDetails.businessDetails` - must be provided, with at least one entry in the `accountHolderDetails.businessDetails.shareholders` - list. - - - * If set to **Individual**, then `accountHolderDetails.individualDetails` - must be provided.' - enum: - - Business - - Individual - - NonProfit - - Partnership - - PublicCompany - type: string - primaryCurrency: - x-addedInVersion: '4' - deprecated: true - description: The primary three-character [ISO currency code](https://docs.adyen.com/development-resources/currency-codes), - to which the account holder should be updated. - type: string - processingTier: - x-addedInVersion: '3' - description: 'The processing tier to which the Account Holder should be - updated. - - >The processing tier can not be lowered through this request. - - - >Required if accountHolderDetails are not provided.' - format: int32 - type: integer - verificationProfile: - x-addedInVersion: '6' - description: The identifier of the profile that applies to this entity. - type: string - required: - - accountHolderCode - type: object - UpdateAccountHolderResponse: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the account holder. - type: string - accountHolderDetails: - description: Details of the account holder. - $ref: '#/components/schemas/AccountHolderDetails' - accountHolderStatus: - x-addedInVersion: '2' - description: The new status of the account holder. - $ref: '#/components/schemas/AccountHolderStatus' - description: - x-addedInVersion: '4' - description: The description of the account holder. - type: string - invalidFields: - x-addedInVersion: '5' - description: in case the account holder has not been updated, contains account - holder fields, that did not pass the validation. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - legalEntity: - x-addedInVersion: '4' - description: The legal entity of the account holder. - enum: - - Business - - Individual - - NonProfit - - Partnership - - PublicCompany - type: string - primaryCurrency: - x-addedInVersion: '5' - deprecated: true - description: The three-character [ISO currency code](https://docs.adyen.com/development-resources/currency-codes), - with which the prospective account holder primarily deals. - type: string - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - verification: - x-addedInVersion: '2' - description: The details of KYC Verification of the account holder. - $ref: '#/components/schemas/KYCVerificationResult' - verificationProfile: - x-addedInVersion: '6' - description: The identifier of the profile that applies to this entity. - type: string - type: object - UpdateAccountHolderStateRequest: - additionalProperties: false - properties: - accountHolderCode: - description: The code of the Account Holder on which to update the state. - type: string - disable: - description: If true, disable the requested state. If false, enable the - requested state. - type: boolean - reason: - description: 'The reason that the state is being updated. - - >Required if the state is being disabled.' - type: string - stateType: - description: 'The state to be updated. - - >Permitted values are: `Processing`, `Payout`' - enum: - - LimitedPayout - - LimitedProcessing - - LimitlessPayout - - LimitlessProcessing - - Payout - - Processing - type: string - required: - - accountHolderCode - - stateType - - disable - type: object - UpdateAccountRequest: - additionalProperties: false - properties: - accountCode: - description: The code of the account to update. - type: string - bankAccountUUID: - x-addedInVersion: '5' - description: The bankAccountUUID of the bank account held by the account - holder to couple the account with. Scheduled payouts in currencies matching - the currency of this bank account will be sent to this bank account. Payouts - in different currencies will be sent to a matching bank account of the - account holder. - type: string - description: - x-addedInVersion: '4' - description: A description of the account, maximum 256 characters.You can - use alphanumeric characters (A-Z, a-z, 0-9), white spaces, and underscores - `_`. - type: string - metadata: - x-addedInVersion: '5' - additionalProperties: - type: string - description: 'A set of key and value pairs for general use by the merchant. - - The keys do not have specific names and may be used for storing miscellaneous - data as desired. - - > Note that during an update of metadata, the omission of existing key-value - pairs will result in the deletion of those key-value pairs.' - type: object - payoutMethodCode: - x-addedInVersion: '5' - description: The payout method code held by the account holder to couple - the account with. Scheduled card payouts will be sent using this payout - method code. - type: string - payoutSchedule: - description: The details of the payout schedule to which the account must - be updated. - $ref: '#/components/schemas/UpdatePayoutScheduleRequest' - payoutSpeed: - x-addedInVersion: '5' - description: 'Speed at which payouts for this account are processed. - - - Possible values: `STANDARD` (default), `SAME_DAY`.' - enum: - - INSTANT - - SAME_DAY - - STANDARD - type: string - required: - - accountCode - type: object - UpdateAccountResponse: - additionalProperties: false - properties: - accountCode: - description: The code of the account. - type: string - bankAccountUUID: - x-addedInVersion: '5' - description: The bankAccountUUID of the bank account held by the account - holder to couple the account with. Scheduled payouts in currencies matching - the currency of this bank account will be sent to this bank account. Payouts - in different currencies will be sent to a matching bank account of the - account holder. - type: string - description: - x-addedInVersion: '4' - description: The description of the account. - type: string - invalidFields: - x-addedInVersion: '5' - description: A list of fields that caused the `/updateAccount` request to - fail. - items: - $ref: '#/components/schemas/ErrorFieldType' - type: array - metadata: - x-addedInVersion: '5' - additionalProperties: - type: string - description: A set of key and value pairs containing metadata. - type: object - payoutMethodCode: - x-addedInVersion: '5' - description: The payout method code held by the account holder to couple - the account with. Scheduled card payouts will be sent using this payout - method code. - type: string - payoutSchedule: - description: The details of the payout schedule to which the account is - updated. - $ref: '#/components/schemas/PayoutScheduleResponse' - payoutSpeed: - x-addedInVersion: '5' - description: 'Speed at which payouts for this account are processed. - - - Possible values: `STANDARD`, `SAME_DAY`.' - enum: - - INSTANT - - SAME_DAY - - STANDARD - type: string - pspReference: - description: The reference of a request. Can be used to uniquely identify - the request. - type: string - resultCode: - description: The result code. - type: string - required: - - accountCode - type: object - UpdatePayoutScheduleRequest: - additionalProperties: false - properties: - action: - description: 'Direction on how to handle any payouts that have already been - scheduled. - - - Possible values: - - * `CLOSE`: close the existing batch of payouts. - - * `UPDATE`: reschedule the existing batch to the new schedule. - - * `NOTHING` (**default**): allow the payout to proceed.' - enum: - - CLOSE - - NOTHING - - UPDATE - type: string - reason: - description: 'The reason for the payout schedule update. - - > This field is required when the `schedule` parameter is set to `HOLD`.' - type: string - schedule: - description: 'The new payout schedule for the account. - - - Possible values: `DEFAULT`, `DAILY`, `DAILY_US`, `DAILY_EU`, `DAILY_AU`, - `DAILY_SG`, `WEEKLY`, `WEEKLY_ON_TUE_FRI_MIDNIGHT`, `BIWEEKLY_ON_1ST_AND_15TH_AT_MIDNIGHT`, - `MONTHLY`, `HOLD`. - - > `HOLD` prevents scheduled payouts, but you can still initiate payouts - manually.' - enum: - - BIWEEKLY_ON_1ST_AND_15TH_AT_MIDNIGHT - - DAILY - - DAILY_AU - - DAILY_EU - - DAILY_SG - - DAILY_US - - HOLD - - MONTHLY - - WEEKLY - - WEEKLY_MON_TO_FRI_AU - - WEEKLY_MON_TO_FRI_EU - - WEEKLY_MON_TO_FRI_US - - WEEKLY_ON_TUE_FRI_MIDNIGHT - - WEEKLY_SUN_TO_THU_AU - - WEEKLY_SUN_TO_THU_US - type: string - required: - - schedule - type: object - UploadDocumentRequest: - additionalProperties: false - properties: - documentContent: - description: 'The content of the document, in Base64-encoded string format. - - - To learn about document requirements, refer to [Verification checks](https://docs.adyen.com/classic-platforms/verification-checks).' - format: byte - type: string - documentDetail: - description: Details of the document being submitted. - $ref: '#/components/schemas/DocumentDetail' - required: - - documentDetail - - documentContent - type: object - ViasAddress: - additionalProperties: false - properties: - city: - description: The name of the city. Required if the `houseNumberOrName`, - `street`, `postalCode`, or `stateOrProvince` are provided. - type: string - country: - description: The two-character country code of the address in ISO-3166-1 - alpha-2 format. For example, **NL**. - type: string - houseNumberOrName: - description: The number or name of the house. - type: string - postalCode: - description: 'The postal code. Required if the `houseNumberOrName`, `street`, - `city`, or `stateOrProvince` are provided. - - - Maximum length: - - - * 5 digits for addresses in the US. - - - * 10 characters for all other countries.' - type: string - stateOrProvince: - description: "The abbreviation of the state or province. Required if the\ - \ `houseNumberOrName`, `street`, `city`, or `postalCode` are provided.\ - \ \n\nMaximum length:\n\n* 2 characters for addresses in the US or Canada.\n\ - \n* 3 characters for all other countries.\n" - type: string - street: - description: The name of the street. Required if the `houseNumberOrName`, - `city`, `postalCode`, or `stateOrProvince` are provided. - type: string - required: - - country - type: object - ViasName: - additionalProperties: false - properties: - firstName: - description: The first name. - maxLength: 80 - type: string - gender: - description: 'The gender. - - >The following values are permitted: `MALE`, `FEMALE`, `UNKNOWN`.' - enum: - - MALE - - FEMALE - - UNKNOWN - maxLength: 1 - type: string - infix: - description: 'The name''s infix, if applicable. - - >A maximum length of twenty (20) characters is imposed.' - maxLength: 20 - type: string - lastName: - description: The last name. - maxLength: 80 - type: string - type: object - ViasPersonalData: - additionalProperties: false - properties: - dateOfBirth: - description: The person's date of birth, in ISO-8601 YYYY-MM-DD format. - For example, **2000-01-31**. - type: string - documentData: - x-addedInVersion: '3' - description: Array that contains information about the person's identification - document. You can submit only one entry per document type. - items: - $ref: '#/components/schemas/PersonalDocumentData' - type: array - nationality: - description: 'The nationality of the person represented by a two-character - country code, in [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) - format. For example, **NL**. - - ' - maxLength: 2 - minLength: 2 - type: string - type: object - ViasPhoneNumber: - additionalProperties: false - properties: - phoneCountryCode: - description: 'The two-character country code of the phone number. - - >The permitted country codes are defined in ISO-3166-1 alpha-2 (e.g. ''NL'').' - type: string - phoneNumber: - description: 'The phone number. - - >The inclusion of the phone number country code is not necessary.' - type: string - phoneType: - description: 'The type of the phone number. - - >The following values are permitted: `Landline`, `Mobile`, `SIP`, `Fax`.' - enum: - - Fax - - Landline - - Mobile - - SIP - type: string - type: object - securitySchemes: - ApiKeyAuth: - in: header - name: X-API-Key - type: apiKey - BasicAuth: - scheme: basic - type: http - examples: - generic-400: - summary: Response code 400. Bad Request. - value: - status: 400 - errorCode: '702' - message: 'Unexpected input: I' - errorType: validation - generic-403: - summary: Response code 403. Forbidden. - value: - status: 403 - errorCode: '10_003' - message: Failed to authorize user - errorType: security - post-checkAccountHolder-basic: - summary: Check the account holder. - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - accountStateType: Processing - tier: '2' - post-closeAccount-closeAccount: - summary: Close an account - value: - accountCode: CODE_OF_ACCOUNT - post-closeAccountHolder-basic: - summary: Close an account holder - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - post-createAccount-basic: - summary: Add an account to an account holder - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - post-createAccountHolder-business: - summary: Create business account holder - value: - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - address: - country: US - businessDetails: - doingBusinessAs: Real Good Restaurant - legalBusinessName: Real Good Restaurant Inc. - shareholders: - - shareholderType: Controller - name: - firstName: John - lastName: Carpenter - address: - country: NL - email: testshareholder@email.com - email: test@email.com - webAddress: https://www.your-website.com - legalEntity: Business - post-createAccountHolder-business-200: - summary: Business account holder created - value: - pspReference: ALPHANUMERIC_UNIQUE_RESPONSE_REFERENCE - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - address: - country: US - bankAccountDetails: [] - businessDetails: - doingBusinessAs: Real Good Restaurant - legalBusinessName: Real Good Restaurant Inc. - shareholders: - - address: - country: NL - email: testshareholder@email.com - name: - firstName: John - lastName: Carpenter - shareholderCode: SHAREHOLDER_CODE - shareholderType: Controller - email: test@email.com - merchantCategoryCode: MCC_DEFAULT_VALUE - payoutMethods: [] - webAddress: https://www.your-website.com - accountHolderStatus: - status: Active - processingState: - disabled: false - processedFrom: - currency: USD - value: 0 - processedTo: - currency: USD - value: 0 - tierNumber: 0 - payoutState: - allowPayout: true - payoutLimit: - currency: USD - value: 0 - disabled: false - tierNumber: 0 - events: [] - legalEntity: Business - invalidFields: [] - verification: {} - post-createAccountHolder-individual: - summary: Create individual account holder - value: - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - email: tim@green.com - individualDetails: - name: - firstName: Tim - lastName: Green - address: - country: US - webAddress: https://www.your-website.com - legalEntity: Individual - post-createAccountHolder-individual-200: - summary: Individual account holder created - value: - pspReference: ALPHANUMERIC_UNIQUE_RESPONSE_REFERENCE - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - address: - country: US - bankAccountDetails: [] - email: tim@green.com - individualDetails: - name: - firstName: Tim - lastName: Green - merchantCategoryCode: '5045' - payoutMethods: [] - webAddress: https://www.your-website.com - accountHolderStatus: - status: Active - processingState: - disabled: false - processedFrom: - currency: USD - value: 0 - processedTo: - currency: USD - value: 0 - tierNumber: 0 - payoutState: - allowPayout: true - payoutLimit: - currency: USD - value: 0 - disabled: false - tierNumber: 0 - events: [] - legalEntity: Individual - invalidFields: [] - verification: {} - post-deleteBankAccounts-basic: - summary: Delete bank accounts - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - bankAccountUUIDs: - - eeb6ed22-3bae-483c-83b9-bc2097a75d40 - post-deleteLegalArrangements-arrangements: - summary: Delete legal arrangements - description: Example request for deleting legal arrangements - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - legalArrangements: - - legalArrangementCode: cdf92f5a-a114-4ce6-8f19-c3f6ec83141c - post-deleteLegalArrangements-arrangements-200: - summary: Legal arrangement deleted - value: - invalidFields: [] - pspReference: '8816080397613514' - post-deleteLegalArrangements-arrangements-400: - summary: Response code 400. Bad Request. - value: - invalidFields: - - errorCode: 34 - errorDescription: An invalid legalArrangementCode code is provided for value - 'cdf92f5a-a114-4ce6-8f19-c3f6ec83141c' - fieldType: - field: AccountHolderDetails.LegalArrangements.legalArrangementCode - fieldName: legalArrangementCode - pspReference: '9916613322577326' - post-deleteLegalArrangements-entities: - summary: Delete legal arrangement entities - description: Example request for deleting legal arrangement entities - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - legalArrangements: - - legalArrangementCode: cdf92f5a-a114-4ce6-8f19-c3f6ec83141c - legalArrangementEntityCodes: - - 755881d3-d6b0-4b34-8ace-1caceb8add63 - post-deleteLegalArrangements-entities-200: - summary: Legal arrangement entities deleted - value: - invalidFields: [] - pspReference: '8816080397613514' - post-deleteLegalArrangements-entities-400: - summary: Response code 400. Bad Request. - value: - invalidFields: - - errorCode: 34 - errorDescription: An invalid legalArrangementEntityCode code is provided - for value 'c92bb932-4867-4cef-bf9d-4ecde37745cf' - fieldType: - field: AccountHolderDetails.LegalArrangements.LegalArrangementsEntities.legalArrangementEntityCode - fieldName: legalArrangementEntityCode - pspReference: '9916613324987358' - post-deletePayoutMethods-basic: - summary: Delete a payout method - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - payoutMethodCodes: - - 34b6ed22-3bae-483c-83b9-bc2097a75d40 - post-deleteShareholders-basic: - summary: Delete shareholders - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - shareholderCodes: - - 9188218c-576e-4cbe-8e86-72722f453920 - post-getAccountHolder-accountCode: - summary: Get an account holder - value: - accountCode: CODE_OF_ACCOUNT - post-getAccountHolder-accountHolderCode: - summary: Get an account holder for the account - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - post-getTaxForm-basic: - summary: Get a tax form - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - formType: 1099-K - year: 2020 - post-getUploadedDocuments-basic: - summary: Get uploaded documents - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - bankAccountUUID: EXAMPLE_UUID - post-suspendAccountHolder-basic: - summary: Suspend an account holder - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - post-unSuspendAccountHolder-basic: - summary: Unsuspend an account holder - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - post-updateAccount-basic: - summary: Set a payout schedule - value: - accountCode: CODE_OF_ACCOUNT - payoutSchedule: - schedule: WEEKLY - action: CLOSE - post-updateAccountHolder-addShareholders: - summary: Add shareholders - value: - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - businessDetails: - shareholders: - - shareholderType: Controller - name: - firstName: Shelly - lastName: Eller - address: - city: San Francisco - country: US - houseNumberOrName: '274' - postalCode: '94107' - stateOrProvince: CA - street: Brannan - email: testshareholder2@email.com - personalData: - dateOfBirth: '1970-01-01' - documentData: - - number: '1234567890' - type: ID - taxId: '123456789' - email: test@email.com - fullPhoneNumber: '+14154890281' - webAddress: http://www.accountholderwebsite.com - post-updateAccountHolder-addShareholders-200: - summary: Shareholders added - value: - invalidFields: [] - pspReference: ALPHANUMERIC_UNIQUE_RESPONSE_REFERENCE - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - bankAccountDetails: [] - businessDetails: - shareholders: - - address: - city: San Francisco - country: US - houseNumberOrName: '274' - postalCode: '94107' - stateOrProvince: CA - street: Brannan - email: testshareholder2@email.com - name: - firstName: Shelly - lastName: Eller - personalData: - dateOfBirth: '1970-01-01' - documentData: - - number: '1234567890' - type: ID - shareholderCode: SHAREHOLDER_CODE - shareholderType: Controller - taxId: '123456789' - email: test@email.com - payoutMethods: [] - webAddress: http://www.accountholderwebsite.com - post-updateAccountHolder-bankAccountDetails: - summary: Update bank account details - value: - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - email: tim@green.com - individualDetails: - name: - firstName: Tim - lastName: Green - bankAccountDetails: - - accountNumber: '1678116852' - branchCode: '053101273' - countryCode: US - currencyCode: USD - ownerName: Tim Green - ownerHouseNumberOrName: '100' - ownerStreet: Main Street - ownerPostalCode: 02894 - ownerCity: Springfield - ownerState: AZ - ownerCountryCode: US - post-updateAccountHolder-bankAccountDetails-200: - summary: Bank account details updated - value: - invalidFields: [] - pspReference: ALPHANUMERIC_UNIQUE_RESPONSE_REFERENCE - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - address: - country: US - bankAccountDetails: - - accountNumber: '######6852' - bankAccountUUID: BANK_ACCOUNT_UUID - branchCode: '053101273' - countryCode: US - currencyCode: USD - ownerCity: Springfield - ownerCountryCode: US - ownerHouseNumberOrName: '100' - ownerName: Tim Green - ownerPostalCode: 02894 - ownerState: AZ - ownerStreet: Main Street - primaryAccount: false - businessDetails: - doingBusinessAs: Real Good Restaurant - legalBusinessName: Real Good Restaurant Inc. - shareholders: - - address: - country: NL - email: testshareholder@email.com - name: - firstName: John - lastName: Carpenter - shareholderCode: SHAREHOLDER_CODE - shareholderType: Controller - email: tim@green.com - merchantCategoryCode: MCC_DEFAULT_VALUE - payoutMethods: [] - webAddress: https://www.your-website.com - accountHolderStatus: - status: Active - processingState: - disabled: false - processedFrom: - currency: USD - value: 0 - processedTo: - currency: USD - value: 0 - tierNumber: 0 - payoutState: - allowPayout: true - payoutLimit: - currency: USD - value: 0 - disabled: false - tierNumber: 0 - legalEntity: Business - verification: {} - post-updateAccountHolder-businessDetails: - summary: Update business details - value: - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - businessDetails: - shareholders: - - shareholderType: Owner - address: - city: Amsterdam - country: NL - houseNumberOrName: '1' - postalCode: 1111AA - stateOrProvince: NH - street: Main Street - email: testshareholder2@email.com - name: - firstName: Shelly - lastName: Eller - taxId: BV123456789 - email: test@email.com - fullPhoneNumber: '+31612345678' - webAddress: http://www.accountholderwebsite.com - post-updateAccountHolder-businessDetails-200: - summary: Business details updated - value: - invalidFields: [] - pspReference: ALPHANUMERIC_UNIQUE_RESPONSE_REFERENCE - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - bankAccountDetails: [] - businessDetails: - shareholders: - - address: - city: Amsterdam - country: NL - houseNumberOrName: '1' - postalCode: 1111AA - stateOrProvince: NH - street: Main Street - email: testshareholder2@email.com - name: - firstName: Shelly - lastName: Eller - shareholderCode: SHAREHOLDER_CODE - shareholderType: Owner - taxId: BV123456789 - email: test@email.com - payoutMethods: [] - webAddress: http://www.accountholderwebsite.com - post-updateAccountHolder-general: - summary: Update individual with documentData property - value: - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - address: - city: NY - country: US - postalCode: '12345' - stateOrProvince: NH - street: Main Street - houseNumberOrName: '100' - email: test@adyen.com - merchantCategoryCode: '7999' - fullPhoneNumber: '+31612345678' - webAddress: http://www.accountholderwebsite.com - post-updateAccountHolder-general-200: - summary: Individual updated with documentData property - value: - invalidFields: [] - pspReference: ALPHANUMERIC_UNIQUE_RESPONSE_REFERENCE - accountHolderCode: YOUR_UNIQUE_ACCOUNT_HOLDER_CODE - accountHolderDetails: - address: - city: NY - country: US - houseNumberOrName: '100' - postalCode: '12345' - stateOrProvince: NH - street: Main Street - bankAccountDetails: [] - businessDetails: - doingBusinessAs: Real Good Restaurant - legalBusinessName: Real Good Restaurant Inc. - shareholders: - - address: - country: NL - email: testshareholder@email.com - name: - firstName: John - lastName: Carpenter - shareholderCode: SHAREHOLDER_CODE - shareholderType: Controller - email: test@adyen.com - merchantCategoryCode: UPDATE_MCC - payoutMethods: [] - phoneNumber: - phoneCountryCode: NL - phoneNumber: '612345678' - phoneType: Landline - webAddress: http://www.accountholderwebsite.com - accountHolderStatus: - status: Active - processingState: - disabled: false - processedFrom: - currency: USD - value: 0 - processedTo: - currency: USD - value: 0 - tierNumber: 0 - payoutState: - allowPayout: true - payoutLimit: - currency: USD - value: 0 - disabled: false - tierNumber: 0 - legalEntity: Business - verification: {} - post-updateAccountHolderState-basic: - summary: Update account holder state - value: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - disable: true - reason: test reason payout - stateType: Payout - post-uploadDocument-basic: - summary: Upload a document - value: - documentContent: dGVzdCBkb2N1bWVudCBjb250ZW50 - documentDetail: - accountHolderCode: CODE_OF_ACCOUNT_HOLDER - documentType: PASSPORT - filename: passport.png - description: test passport description diff --git a/labs/lab-06-api-lifecycle-management/README.md b/labs/lab-06-api-lifecycle-management/README.md index 871aacd..9d19c3a 100644 --- a/labs/lab-06-api-lifecycle-management/README.md +++ b/labs/lab-06-api-lifecycle-management/README.md @@ -120,8 +120,26 @@ export default createApp({ }); ``` -The module adds one `EntityCardBlueprint` info card (`ApiVersionsCard.tsx`) to every `kind:API` -entity's page — no new backend module, no new dependency. +The module adds two `EntityCardBlueprint` cards to every `kind:API` entity's page — no new backend +module, no new dependency: + +- **`ApiVersionsCard.tsx`** — an `info` card (sidebar) listing sibling versions. +- **`ApiLifecycleBanner.tsx`** — a `content` card (main column) that renders nothing unless + `spec.lifecycle` is `deprecated` or `retired`, in which case it shows a loud warning naming the + latest version to use instead. The Versions card alone is easy to miss; this puts the same + "don't use this" signal where you're already looking. + +Card order in the main content column otherwise follows plugin/module discovery order, not +anything declared in `apiVersions/index.ts` — the auto-discovered `api-docs` Definition card would +render above the banner by default. Add one entry to `app-config.yaml`'s `app.extensions` list to +pin the banner first (Backstage has treated app-config extension order as authoritative since +v1.27 — see [the frontend-system override docs](https://github.com/backstage/backstage/blob/master/docs/frontend-system/architecture/25-extension-overrides.md)): + +```yaml +app: + extensions: + - entity-card:catalog/api-lifecycle-banner +``` ## Step 4 — Start Backstage and Confirm Both Versions Are Registered @@ -145,19 +163,29 @@ differ. ## Step 6 — Advance v2's Lifecycle +> **WARNING** +> +> **Every edit in Steps 6–8 must be committed and pushed to your fork before Backstage will see +> it.** Step 2's catalog locations are `type: url` entries pointing at +> `raw.githubusercontent.com//...` — Backstage fetches the file from GitHub, not from +> your local working copy. Editing the YAML locally and refreshing the page does nothing on its +> own; commit, push to the branch your location URLs point at (`main`, unless you changed it), then +> wait for the catalog's normal refresh interval (or trigger one immediately via `POST +> /api/catalog/refresh`). + Edit `museum-api-v2.yaml`'s `spec.lifecycle`: change it from `development` to `testing`, then from -`testing` to `production`. After each edit, refresh either version's page — the Versions card and -v2's own About card should reflect the new value within Backstage's normal catalog refresh -interval, while `museum-api-v1` stays unaffected throughout. +`testing` to `production`. After each edit, **commit and push**, then refresh either version's +page — the Versions card and v2's own About card should reflect the new value within Backstage's +normal catalog refresh interval, while `museum-api-v1` stays unaffected throughout. ## Step 7 — Deprecate v1 -Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `deprecated`. Confirm the Deprecated label appears -on v1's own page **and** in the Versions card on both v1 and v2's pages. +Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `deprecated`, then **commit and push**. Confirm the +Deprecated label appears on v1's own page **and** in the Versions card on both v1 and v2's pages. ## Step 8 — Retire v1 -Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `retired`. Confirm: +Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `retired`, then **commit and push**. Confirm: - v1 disappears from the Versions card's default (expanded) list on both pages. - A "Show retired versions (1)" button appears; clicking it reveals v1, clearly labeled Retired. @@ -250,9 +278,12 @@ Fixed — this is the mechanism itself, not a convention: to remove Lab 2's original catalog location entry from `app-config.yaml` in Step 2 — the old and new entities can coexist harmlessly, but that defeats the "supersede, don't duplicate" point of this lab. -- **Lifecycle edit doesn't show up immediately**: catalog processing runs on its normal refresh - interval; give it a few seconds and refresh the page, or trigger an immediate refresh via - Backstage's `POST /api/catalog/refresh` endpoint with the entity's ref. +- **Lifecycle edit doesn't show up at all, no matter how long you wait**: you edited the local + file but didn't commit and push. These catalog locations are `type: url`, so Backstage reads + from your fork's GitHub remote, never your local disk — see the note above Step 6. +- **Lifecycle edit doesn't show up immediately after pushing**: catalog processing runs on its + normal refresh interval; give it a few seconds and refresh the page, or trigger an immediate + refresh via Backstage's `POST /api/catalog/refresh` endpoint with the entity's ref. - **v2's spec fails to resolve (`$text` 404)**: if you're working from a fork, update the `$text` URL in `museum-api-v2.yaml` to point at your fork's raw URL, not this repository's. diff --git a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiLifecycleBanner.tsx b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiLifecycleBanner.tsx new file mode 100644 index 0000000..40e7536 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiLifecycleBanner.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useEntity, EntityRefLink } from '@backstage/plugin-catalog-react'; +import { WarningPanel } from '@backstage/core-components'; +import { + DEPRECATED, + RETIRED, + findLatest, + getLifecycle, + useApiSiblings, +} from './versionUtils'; + +// A loud, main-content-area warning for deprecated/retired API versions — the sidebar +// Versions card exists too, but it's easy to miss, so this makes the two "don't use this" +// states impossible to overlook when a learner (or a real API consumer) lands on the page. +export function ApiLifecycleBanner() { + const { entity } = useEntity(); + const { siblings } = useApiSiblings(entity); + const lifecycle = getLifecycle(entity); + + if (lifecycle !== DEPRECATED && lifecycle !== RETIRED) { + return null; + } + + const latest = siblings && findLatest(siblings); + const latestIsSelf = latest?.metadata.name === entity.metadata.name; + + const severity = lifecycle === RETIRED ? 'error' : 'warning'; + const verb = lifecycle === RETIRED ? 'has been retired' : 'is deprecated'; + const title = lifecycle === RETIRED ? 'This API is retired' : 'This API is deprecated'; + + return ( +
+ + This API version {verb} and should not be used for new integrations. + {latest && !latestIsSelf ? ( + <> + {' '} + Use{' '} + + {latest.metadata.name} + {' '} + instead. + + ) : null} + +
+ ); +} diff --git a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx index 3918827..c427f84 100644 --- a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx @@ -1,10 +1,5 @@ -import React, { useEffect, useState } from 'react'; -import { useApi } from '@backstage/core-plugin-api'; -import { - catalogApiRef, - useEntity, - EntityRefLink, -} from '@backstage/plugin-catalog-react'; +import React, { useState } from 'react'; +import { useEntity, EntityRefLink } from '@backstage/plugin-catalog-react'; import { InfoCard, Progress, ResponseErrorPanel } from '@backstage/core-components'; import { Entity } from '@backstage/catalog-model'; import { @@ -15,39 +10,15 @@ import { ListItemText, Typography, } from '@material-ui/core'; - -const VERSION_ANNOTATION = 'apiportal.io/version'; -const RETIRED = 'retired'; - -function getVersion(entity: Entity): [number, number] { - const raw = entity.metadata.annotations?.[VERSION_ANNOTATION]; - const [major, minor] = (raw ?? '0.0').split('.').map(Number); - return [major || 0, minor || 0]; -} - -function isRetired(entity: Entity): boolean { - return entity.spec?.lifecycle === RETIRED; -} - -function getLifecycle(entity: Entity): string | undefined { - return entity.spec?.lifecycle as string | undefined; -} - -function compareVersions(a: Entity, b: Entity): number { - const [aMajor, aMinor] = getVersion(a); - const [bMajor, bMinor] = getVersion(b); - return aMajor !== bMajor ? aMajor - bMajor : aMinor - bMinor; -} - -// "Latest" is computed from every sibling's version annotation, not manually flagged, to -// avoid two versions ever claiming "latest" at once (research.md R2). Prefer the highest -// non-retired version; if every sibling is retired, fall back to the highest overall -// (spec.md edge case: no current version at all). -function findLatest(siblings: Entity[]): Entity | undefined { - const active = siblings.filter(s => !isRetired(s)); - const pool = active.length > 0 ? active : siblings; - return [...pool].sort(compareVersions).pop(); -} +import { + RETIRED, + compareVersions, + findLatest, + getLifecycle, + getVersion, + isRetired, + useApiSiblings, +} from './versionUtils'; function VersionRow({ sibling, @@ -99,29 +70,11 @@ function VersionRow({ export function ApiVersionsCard() { const { entity } = useEntity(); - const catalogApi = useApi(catalogApiRef); - const [siblings, setSiblings] = useState(); - const [error, setError] = useState(); + const { siblings, error } = useApiSiblings(entity); const [showRetired, setShowRetired] = useState(false); const system = entity.spec?.system as string | undefined; - useEffect(() => { - if (!system) { - setSiblings([]); - return; - } - catalogApi - .getEntities({ - filter: { - kind: 'API', - 'spec.system': system, - }, - }) - .then(response => setSiblings(response.items)) - .catch(setError); - }, [catalogApi, system]); - if (!system) { return ( diff --git a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/index.ts b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/index.ts index 684a5d7..dbd13d2 100644 --- a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/index.ts +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/index.ts @@ -2,6 +2,7 @@ import { createFrontendModule } from '@backstage/frontend-plugin-api'; import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha'; import React from 'react'; import { ApiVersionsCard } from './ApiVersionsCard'; +import { ApiLifecycleBanner } from './ApiLifecycleBanner'; const apiVersionsCard = EntityCardBlueprint.make({ name: 'api-versions', @@ -12,7 +13,19 @@ const apiVersionsCard = EntityCardBlueprint.make({ }, }); +// A 'content' card, not 'info' — it renders in the main content column (alongside the About +// card) instead of the sidebar, so a deprecated/retired version is hard to miss. Renders +// nothing for any other lifecycle value. +const apiLifecycleBanner = EntityCardBlueprint.make({ + name: 'api-lifecycle-banner', + params: { + filter: 'kind:API', + type: 'content', + loader: async () => React.createElement(ApiLifecycleBanner), + }, +}); + export const apiVersionsModule = createFrontendModule({ pluginId: 'catalog', - extensions: [apiVersionsCard], + extensions: [apiVersionsCard, apiLifecycleBanner], }); diff --git a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts new file mode 100644 index 0000000..3e69ed6 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react'; +import { useApi } from '@backstage/core-plugin-api'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { Entity } from '@backstage/catalog-model'; + +export const VERSION_ANNOTATION = 'apiportal.io/version'; +export const RETIRED = 'retired'; +export const DEPRECATED = 'deprecated'; + +export function getVersion(entity: Entity): [number, number] { + const raw = entity.metadata.annotations?.[VERSION_ANNOTATION]; + const [major, minor] = (raw ?? '0.0').split('.').map(Number); + return [major || 0, minor || 0]; +} + +export function isRetired(entity: Entity): boolean { + return entity.spec?.lifecycle === RETIRED; +} + +export function getLifecycle(entity: Entity): string | undefined { + return entity.spec?.lifecycle as string | undefined; +} + +export function compareVersions(a: Entity, b: Entity): number { + const [aMajor, aMinor] = getVersion(a); + const [bMajor, bMinor] = getVersion(b); + return aMajor !== bMajor ? aMajor - bMajor : aMinor - bMinor; +} + +// "Latest" is computed from every sibling's version annotation, not manually flagged, to +// avoid two versions ever claiming "latest" at once (research.md R2). Prefer the highest +// non-retired version; if every sibling is retired, fall back to the highest overall +// (spec.md edge case: no current version at all). +export function findLatest(siblings: Entity[]): Entity | undefined { + const active = siblings.filter(s => !isRetired(s)); + const pool = active.length > 0 ? active : siblings; + return [...pool].sort(compareVersions).pop(); +} + +// Shared by the Versions card and the deprecated/retired banner so both agree on the same +// sibling set and the same "latest" computation (research.md R2). +export function useApiSiblings(entity: Entity): { + siblings: Entity[] | undefined; + error: Error | undefined; +} { + const catalogApi = useApi(catalogApiRef); + const [siblings, setSiblings] = useState(); + const [error, setError] = useState(); + + const system = entity.spec?.system as string | undefined; + + useEffect(() => { + if (!system) { + setSiblings([]); + return; + } + catalogApi + .getEntities({ + filter: { + kind: 'API', + 'spec.system': system, + }, + }) + .then(response => setSiblings(response.items)) + .catch(setError); + }, [catalogApi, system]); + + return { siblings, error }; +} diff --git a/specs/006-api-lifecycle-management/checklists/issues.md b/specs/006-api-lifecycle-management/checklists/issues.md new file mode 100644 index 0000000..e3f0645 --- /dev/null +++ b/specs/006-api-lifecycle-management/checklists/issues.md @@ -0,0 +1,2 @@ +## Run 1 - 2026/07/05 + From 7d704d5988da2dfbfc29f29a3bde96fe4faebb1c Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 5 Jul 2026 21:41:01 +1000 Subject: [PATCH 08/11] Catalog yaml-less API lifecycle initial version --- labs/lab-04-auto-registration/README.md | 5 +- .../src/extensions/autoApiRegistration.ts | 80 ++- .../002_add_system_slug.ts | 21 + .../lab-06-api-lifecycle-management/README.md | 321 ++++++----- .../apis/museum-v1/museum-v1-openapi.yaml | 502 ++++++++++++++++++ .../museum-v2/museum-v2-openapi.yaml} | 15 +- .../catalog/apis/museum-api-v1.yaml | 32 -- .../catalog/apis/museum-api-v2.yaml | 31 -- .../catalog/system.yaml | 18 - .../modules/apiVersions/ApiVersionsCard.tsx | 8 +- .../src/modules/apiVersions/versionUtils.ts | 42 +- .../checklists/issues.md | 10 + .../data-model.md | 115 ++-- specs/006-api-lifecycle-management/plan.md | 181 ++++--- .../quickstart.md | 57 +- .../006-api-lifecycle-management/research.md | 163 ++++-- specs/006-api-lifecycle-management/tasks.md | 328 ++++++++---- 17 files changed, 1377 insertions(+), 552 deletions(-) create mode 100644 labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistrationMigrations/002_add_system_slug.ts create mode 100644 labs/lab-06-api-lifecycle-management/apis/museum-v1/museum-v1-openapi.yaml rename labs/lab-06-api-lifecycle-management/{catalog/apis/museum-v2/openapi.yaml => apis/museum-v2/museum-v2-openapi.yaml} (93%) delete mode 100644 labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml delete mode 100644 labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml delete mode 100644 labs/lab-06-api-lifecycle-management/catalog/system.yaml diff --git a/labs/lab-04-auto-registration/README.md b/labs/lab-04-auto-registration/README.md index 8ca2198..0924b36 100644 --- a/labs/lab-04-auto-registration/README.md +++ b/labs/lab-04-auto-registration/README.md @@ -263,7 +263,10 @@ Three failure modes, each temporary — revert after checking: **Adaptable — change these to fit your own repo layout:** -- `rootPath` — where the scan starts. Point it at your own mono-repo root. +- `rootPath` — where the scan starts. Point it at your own mono-repo root. A relative path is + resolved against `packages/backend`'s own directory (the same anchor the module's built-in + default uses), not wherever `yarn start` happens to be invoked from — see Lab 6's second + `autoApiRegistration` source for a worked example. - `patterns` — the filename glob(s) that identify an API definition file. The default (`**/*-openapi.yaml`, `**/*-asyncapi.yaml`) is this lab's convention, not Backstage's; use whatever your team already follows. diff --git a/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts b/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts index 45529f2..187a8dd 100644 --- a/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts +++ b/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts @@ -44,6 +44,7 @@ import type { Entity } from '@backstage/catalog-model'; import { InputError } from '@backstage/errors'; import type { Knex } from 'knex'; import * as scanStateCacheMigration from './autoApiRegistrationMigrations/001_scan_state_cache'; +import * as systemSlugMigration from './autoApiRegistrationMigrations/002_add_system_slug'; // --------------------------------------------------------------------------------------------- // Config @@ -118,9 +119,16 @@ export function normalizeConfig(rootConfig: RootConfigService): SourceConfig[] { ); } + const rawRootPath = entry.getOptionalString('rootPath'); return { id, - rootPath: entry.getOptionalString('rootPath') ?? defaultRootPath(), + // A relative rootPath is resolved against this backend package's own directory, not the + // process's cwd (which varies by how `yarn start` was invoked) — same anchor + // defaultRootPath() itself uses, so every source's `rootPath` behaves consistently + // regardless of where the dev server happens to be launched from. + rootPath: rawRootPath + ? path.resolve(resolvePackagePath('backend'), rawRootPath) + : defaultRootPath(), patterns: entry.getOptionalStringArray('patterns') ?? DEFAULT_PATTERNS, ignore: entry.getOptionalStringArray('ignore') ?? DEFAULT_IGNORE, mode: (entry.getOptionalString('mode') as 'poll' | 'watch') ?? 'poll', @@ -217,6 +225,9 @@ export function slugify(title: string): string { interface MappingResult { entity: Entity; entityName: string; + // Lab 6: set only on a successful (non-error) mapping that declares an apiBasename — error/ + // marker entities never contribute a System, since they aren't real registrations. + systemSlug?: string; error?: string; } @@ -243,6 +254,7 @@ function buildEntity(opts: { owner: string; lifecycle: string; visibility: string; + system?: string; registrationError?: string; }): Entity { const annotations: Record = { @@ -273,11 +285,31 @@ function buildEntity(opts: { type: opts.parsed.kind, lifecycle: opts.lifecycle, owner: opts.owner, + ...(opts.system ? { system: opts.system } : {}), definition: opts.contents, }, }; } +// Lab 6: builds the System entity representing an apiBasename — one instance per distinct slug, +// synthesized purely from spec files, never hand-authored (see runCycle()'s per-cycle +// resynchronization). +function buildSystemEntity(providerName: string, slug: string, owner: string): Entity { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'System', + metadata: { + name: slug, + annotations: { + [MANAGED_BY_ANNOTATION]: providerName, + }, + }, + spec: { + owner, + }, + }; +} + /** * Maps a parsed spec file to a candidate catalog entity, applying x-* extraction, owner/visibility * validation, and collision detection. Does not perform the catalog-info.yaml precedence check @@ -300,6 +332,10 @@ function mapCandidate(opts: { const lifecycle = xField(parsed.raw, source.xNamespace, 'lifecycle') ?? 'experimental'; const rawVisibility = xField(parsed.raw, source.xNamespace, 'visibility'); const visibility = rawVisibility ?? source.defaultVisibility; + // Lab 6: x-.apiBasename groups every major version of the same logical API under one + // System, slugified the same way entity names are so it's a valid catalog entity name. + const rawApiBasename = xField(parsed.raw, source.xNamespace, 'apiBasename'); + const systemSlug = rawApiBasename ? slugify(rawApiBasename) : undefined; // Rule: an unrecognized-but-present visibility value is a marker/error entity, never silently // defaulted. @@ -364,6 +400,7 @@ function mapCandidate(opts: { return { entityName: baseSlug, + systemSlug, entity: buildEntity({ providerName, filePath, @@ -373,6 +410,7 @@ function mapCandidate(opts: { owner, lifecycle, visibility, + system: systemSlug, }), }; } @@ -388,6 +426,9 @@ interface CacheRow { content_hash: string; entity_name: string; last_error: string | null; + // Lab 6: the slugified info.x-.apiBasename this file last resolved to, or null if + // the file doesn't declare one. Drives System entity synthesis in runCycle(). + system_slug: string | null; } class ScanStateCache { @@ -400,17 +441,21 @@ class ScanStateCache { // `knex_migrations` table here would validate our one-migration `migrationSource` against the // real catalog plugin's own (much longer) applied-migrations history and fail with // "migration directory is corrupt". + const migrations: Record = { + '001_scan_state_cache': scanStateCacheMigration, + '002_add_system_slug': systemSlugMigration, + }; await knex.migrate.latest({ tableName: 'auto_api_registration_migrations', migrationSource: { async getMigrations() { - return ['001_scan_state_cache']; + return Object.keys(migrations); }, getMigrationName(migration: string) { return migration; }, - async getMigration() { - return scanStateCacheMigration; + async getMigration(migration: string) { + return migrations[migration]; }, }, }); @@ -584,6 +629,7 @@ class AutoApiRegistrationEntityProvider implements EntityProvider { content_hash: hash, entity_name: mapped.entityName, last_error: mapped.error ?? null, + system_slug: mapped.systemSlug ?? null, }); }); @@ -594,14 +640,36 @@ class AutoApiRegistrationEntityProvider implements EntityProvider { } } + // Lab 6: recompute the full set of System entities this source currently implies, from every + // file's last-known system_slug (not just the ones that changed this cycle) — cheap (in-memory + // over the cache rows already loaded) and re-declaring an unchanged System is a harmless + // upsert, so this stays correct even across a backend restart's first cycle. + const deletedPaths = new Set(deletes); + const finalRows: CacheRow[] = [...upserts]; + for (const [filePath, row] of existingByPath) { + if (currentPaths.has(filePath) && !changedPaths.includes(filePath) && !deletedPaths.has(filePath)) { + finalRows.push(row); + } + } + const systemSlugs = new Set( + finalRows.map(row => row.system_slug).filter((slug): slug is string => !!slug), + ); + const systemEntities: DeferredEntity[] = [...systemSlugs].map(slug => ({ + entity: buildSystemEntity( + this.getProviderName(), + slug, + resolveOwnerRef(undefined, this.source.defaultOwner), + ), + })); + if (!this.hasRunOnce) { // First run: establish the baseline with one `full` mutation. - await this.connection.applyMutation({ type: 'full', entities: added }); + await this.connection.applyMutation({ type: 'full', entities: [...added, ...systemEntities] }); this.hasRunOnce = true; } else if (added.length > 0 || removedRefs.length > 0) { await this.connection.applyMutation({ type: 'delta', - added, + added: [...added, ...systemEntities], removed: removedRefs.map(entityRef => ({ entityRef })), }); } diff --git a/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistrationMigrations/002_add_system_slug.ts b/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistrationMigrations/002_add_system_slug.ts new file mode 100644 index 0000000..59383a4 --- /dev/null +++ b/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistrationMigrations/002_add_system_slug.ts @@ -0,0 +1,21 @@ +// packages/backend/src/extensions/autoApiRegistrationMigrations/002_add_system_slug.ts +// +// Lab 6: adds the column that lets the scan-state cache remember which logical-API grouping +// (info.x-.apiBasename, slugified) each registered file last resolved to, so the +// provider can synthesize/refresh the matching System entity across cycles without re-parsing +// every file every time. Nullable — files with no apiBasename simply never populate it. + +import type { Knex } from 'knex'; +import { TABLE_NAME } from './001_scan_state_cache'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable(TABLE_NAME, table => { + table.string('system_slug').nullable(); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable(TABLE_NAME, table => { + table.dropColumn('system_slug'); + }); +} diff --git a/labs/lab-06-api-lifecycle-management/README.md b/labs/lab-06-api-lifecycle-management/README.md index 9d19c3a..399b764 100644 --- a/labs/lab-06-api-lifecycle-management/README.md +++ b/labs/lab-06-api-lifecycle-management/README.md @@ -10,97 +10,125 @@ alongside the original, and demonstrates how Backstage represents "these are ver logical API," which version is prominent by default, and how a version is deprecated and eventually retired without ever deleting its history. +Lab 4 already built a mechanism for exactly this kind of problem: a file-glob `EntityProvider` +that reads OpenAPI/AsyncAPI specs directly and extracts owner/lifecycle/visibility from an +`info.x-` extension block, so no hand-authored `catalog-info.yaml` is ever needed. This +lab **extends that same provider** (one additive change to +`labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts`, still +backward-compatible with every existing Lab 4 spec) rather than reinventing catalog descriptors: +zero hand-authored catalog YAML is added by this lab, for either the two API versions or the +System that groups them. + By the end of this lab you will have: -- Two Museum API versions — `museum-api-v1` (Lab 1's original, unmodified) and `museum-api-v2` (a - new spec with deliberate breaking changes) — registered in the catalog **at the same time**, - grouped under one new `System` entity (`museum-api`) via Backstage's native `spec.system` +- Two Museum API versions — `museum-api-v1` and `museum-api-v2` (a new spec with deliberate + breaking changes) — both **auto-registered** from local OpenAPI files (no catalog-info.yaml), + grouped under one **auto-synthesized** `System` entity via Backstage's native `spec.system` relation. - A new frontend module (`packages/app/src/modules/apiVersions/`) that adds an "API Versions" - card to every API's page, listing its sibling versions, computing and flagging the latest one, - showing each version's own lifecycle chip, and collapsing retired versions behind a "Show - retired versions" toggle. + card to every API's page, listing its sibling versions, computing and flagging the latest one + (read straight from each spec's own `info.version` — never a duplicated annotation), showing + each version's own lifecycle chip, and collapsing retired versions behind a "Show retired + versions" toggle. - A worked walkthrough of the full lifecycle: v2 starting in `development` and advancing through `testing` to `production`, while v1 is marked `deprecated` and then `retired` — with v1's entity - never deleted at any point. + never deleted at any point. Every transition is a one-line edit to the spec file's own + `x-examplecorp.lifecycle` field — no catalog YAML to touch, no git push required. **What you will learn:** - Why a `System` entity — not a bespoke annotation — is the right native mechanism for "these - entities are versions of one logical API" (see "Why a System entity" below) -- Why "latest version" is computed from a version annotation rather than a manually-maintained - flag, and what goes wrong with the manual-flag approach + entities are versions of one logical API," and why that `System` is worth auto-synthesizing + from a spec-level field rather than hand-authoring (see "Why a System Entity" below) +- Why "latest version" is read directly from `info.version` (data the spec already has) rather + than a parallel `apiportal.io/version` annotation, and what goes wrong with a duplicated copy +- Why lifecycle state belongs in the spec's own `x-` extension block, picked up by the + same mechanism Lab 4 already built for owner/visibility, instead of a hand-authored + `spec.lifecycle` override that fights the source of truth - Why this lab deliberately leaves Backstage's built-in search and catalog table unmodified, and where the "default" browsing experience actually lives instead -- Why `spec.lifecycle` — a field every API entity has already had since Lab 1 — is enough to - represent five lifecycle states, including one (`retired`) Backstage doesn't define itself --- ## Prerequisites - **Labs 1–5 completed** — Backstage running locally with Museum, Streetlights, Train Travel, - Galaxy, and the precedence-demo API all registered, plus Lab 5's mocking/testing setup + Galaxy, and the precedence-demo API all registered, plus Lab 4's auto-registration module and + Lab 5's mocking/testing setup - **Node.js 20+ (or 22/24) and Yarn** — same toolchain as Labs 1–5 -- No new external accounts, services, or OS-level prerequisites, and no new dependencies to - install — this lab only adds catalog YAML, one new OpenAPI spec, and one small frontend module +- No new external accounts, services, or OS-level prerequisites, and no new dependency to + install — `js-yaml` (used to read `info.version` client-side) is already a `packages/app` + dependency as of Lab 5's mocking module --- -## Step 1 — Add the System and Both API Versions to the Catalog +## Step 1 — Add the Two Museum API Spec Files + +Unlike Labs 2–5's catalog-info.yaml-based registrations, there is no catalog YAML to author here +at all. This lab adds two local OpenAPI files, already in place at +[`apis/museum-v1/museum-v1-openapi.yaml`](apis/museum-v1/museum-v1-openapi.yaml) and +[`apis/museum-v2/museum-v2-openapi.yaml`](apis/museum-v2/museum-v2-openapi.yaml): -Copy this lab's catalog files into your Backstage workspace's catalog location list. If you're -working directly in this repository (not a separate clone), the files are already at -[`catalog/system.yaml`](catalog/system.yaml), [`catalog/apis/museum-api-v1.yaml`](catalog/apis/museum-api-v1.yaml), -[`catalog/apis/museum-api-v2.yaml`](catalog/apis/museum-api-v2.yaml), and -[`catalog/apis/museum-v2/openapi.yaml`](catalog/apis/museum-v2/openapi.yaml). +- **`museum-v1-openapi.yaml`** is a content-identical copy of Lab 1's `museum/openapi.yaml` + (Lab 1's own committed file is never edited — see "Why Supersede Lab 2's Entry, Not Edit It" + below for why a local copy, not a `$text` reference, is required here). Titled + **"Museum API v1"**, not just "Museum API" — see the file's own `info.title` comment for why. +- **`museum-v2-openapi.yaml`** is a new spec, titled **"Museum API v2"**, with two deliberate + breaking changes vs. v1: the `/special-events/{eventId}` path parameter is renamed to `{id}`, + and `GET /tickets/{ticketId}/qr` is renamed to `GET /tickets/{ticketId}/qr-code`. Both changes + are called out in the spec's own `info.description`. -- **`system.yaml`** registers `system:default/museum-api` — the logical grouping every version - will point at via `spec.system`. -- **`museum-api-v1.yaml`** registers a *new* entity name, `museum-api-v1`, pointing its - `spec.definition.$text` at the exact same, unmodified OpenAPI file Lab 1 already registered - (`labs/lab-01-base-backstage/apis/museum/openapi.yaml`). It supersedes Lab 2's single - `museum-api` entity — see "Why supersede, not edit" below. -- **`museum-api-v2.yaml`** registers `museum-api-v2`, pointing at a **new** spec file - ([`catalog/apis/museum-v2/openapi.yaml`](catalog/apis/museum-v2/openapi.yaml)) with two - deliberate breaking changes: the `/special-events/{eventId}` path parameter is renamed to - `{id}`, and `GET /tickets/{ticketId}/qr` is renamed to `GET /tickets/{ticketId}/qr-code`. Both - changes are called out in the spec's own `info.description`. +Both specs carry an `info.x-examplecorp` block — the same `x-` extension mechanism Lab 4 +already introduced for owner/lifecycle/visibility: -Both version entities carry: +```yaml +info: + version: 1.0.0 # read directly for "latest version" — see "Why Version Comes From info.version" + x-examplecorp: + owner: group:default/museum-team + visibility: private + lifecycle: production # v2 starts at `development` — this is the field Steps 6-8 edit + apiBasename: museum-api # groups every version of this API under one auto-synthesized System +``` -- `annotations['apiportal.io/version']` — a `"major.minor"` string (`"1.0"`, `"2.0"`) used to sort - versions and compute the latest one. -- `spec.system: museum-api` — the relation that groups them. -- `spec.lifecycle` — v1 starts at `production`, v2 starts at `development`. +`apiBasename` is new in this lab — see "Why apiBasename Drives System Assignment" below for why +it exists and how the provider turns it into a `System` entity with no catalog YAML of its own. -## Step 2 — Update `app-config.yaml`'s Catalog Locations +## Step 2 — Register a Second `autoApiRegistration` Source -In `app-config.yaml`'s `catalog.locations`, **remove** Lab 2's single "Museum REST API" location -entry and **add** three new entries — one for the System, one for each version: +`app-config.yaml`'s `autoApiRegistration` config is already in place, converted from Lab 4's +single-source shorthand to the explicit multi-source form (see +[`labs/lab-04-auto-registration/README.md`'s "Scaling to Multiple Source Repositories"](../lab-04-auto-registration/README.md#scaling-to-multiple-source-repositories)): ```yaml - # --- Lab 6: Museum API System (groups museum-api-v1/v2 as versions of one logical API) --- - - type: url - target: https://raw.githubusercontent.com//backstage-apiportal-lab/main/labs/lab-06-api-lifecycle-management/catalog/system.yaml - rules: - - allow: [System] - - # --- Lab 6: Museum API v1 --- - - type: url - target: https://raw.githubusercontent.com//backstage-apiportal-lab/main/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml - rules: - - allow: [API] - - # --- Lab 6: Museum API v2 (deliberate breaking changes vs. v1) --- - - type: url - target: https://raw.githubusercontent.com//backstage-apiportal-lab/main/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml - rules: - - allow: [API] +autoApiRegistration: + sources: + - id: default + defaultOwner: group:default/platform-team + defaultVisibility: private + xNamespace: examplecorp + + # --- Lab 6: Museum API (all major versions + their System, all auto-registered) --- + - id: lab6-museum-api + rootPath: ../../../../lab-06-api-lifecycle-management/apis + defaultOwner: group:default/museum-team + defaultVisibility: private + xNamespace: examplecorp ``` -Replace `` with your own GitHub username/organization, matching the pattern already -used by every earlier lab's entries in this same file. +This is its own source (own `id`, own `rootPath`, own `defaultOwner`) rather than a change to +Lab 4's `default` source, exactly as the museum team's own repo would be onboarded independently +at real scale (Lab 4 README's "no built-in global fallback" rationale for `defaultOwner`). It +shares `xNamespace: examplecorp` with the `default` source deliberately: `x-examplecorp` is +`examplecorp`-the-company's vendor extension namespace for *any* API metadata a producer wants to +declare about their spec — owner, lifecycle, visibility, now `apiBasename` — not something scoped +to Backstage or "the API Portal" specifically. Every team at `examplecorp` uses the same namespace +regardless of which repo or catalog source picks their specs up; introducing a per-tool namespace +(e.g. `x-apiportal`) would wrongly imply the metadata exists *for* Backstage, when other systems +(a CLI linter, an internal API gateway, a docs generator) are equally valid consumers of the same +`x-examplecorp` block. Lab 2's original single "Museum REST API" `catalog.locations` entry has +also been **removed** — this lab's two auto-registered versions supersede it (see "Why Supersede +Lab 2's Entry, Not Edit It" below). ## Step 3 — Add the API Versions Frontend Module @@ -121,7 +149,8 @@ export default createApp({ ``` The module adds two `EntityCardBlueprint` cards to every `kind:API` entity's page — no new backend -module, no new dependency: +module, no new dependency (`js-yaml`, used to read `info.version` from `spec.definition`, is +already present as of Lab 5): - **`ApiVersionsCard.tsx`** — an `info` card (sidebar) listing sibling versions. - **`ApiLifecycleBanner.tsx`** — a `content` card (main column) that renders nothing unless @@ -148,44 +177,42 @@ cd labs/lab-01-base-backstage/backstage yarn start ``` -Open the catalog and confirm: +Within one `autoApiRegistration` poll cycle, open the catalog and confirm: -- `museum-api-v1` and `museum-api-v2` both appear as separate entities. -- The `museum-api` System's page lists both as "Has part" APIs. +- `museum-api-v1` and `museum-api-v2` both appear as separate entities — with no + `catalog-info.yaml` for either. +- A `museum-api` `System` entity exists, listing both as "Has part" APIs, even though no + `system.yaml` was ever authored — it was synthesized from both specs' matching + `x-examplecorp.apiBasename: museum-api`. - Opening either version's page shows the new "API Versions" card, listing both versions with - working links between them, and `museum-api-v2` flagged **Latest**. + working links between them, and `museum-api-v2` flagged **Latest** (computed from each spec's + `info.version`, `1.0.0` vs. `2.0.0`). ## Step 5 — Confirm Independent Lifecycle State -`museum-api-v1` starts at `production`, `museum-api-v2` starts at `development`. Confirm each -version's own "About" card and its row in the Versions card show its own state, and that the two -differ. +`museum-api-v1` starts at `production`, `museum-api-v2` starts at `development` (each spec's own +`x-examplecorp.lifecycle`). Confirm each version's own "About" card and its row in the Versions card +show its own state, and that the two differ. ## Step 6 — Advance v2's Lifecycle -> **WARNING** -> -> **Every edit in Steps 6–8 must be committed and pushed to your fork before Backstage will see -> it.** Step 2's catalog locations are `type: url` entries pointing at -> `raw.githubusercontent.com//...` — Backstage fetches the file from GitHub, not from -> your local working copy. Editing the YAML locally and refreshing the page does nothing on its -> own; commit, push to the branch your location URLs point at (`main`, unless you changed it), then -> wait for the catalog's normal refresh interval (or trigger one immediately via `POST -> /api/catalog/refresh`). - -Edit `museum-api-v2.yaml`'s `spec.lifecycle`: change it from `development` to `testing`, then from -`testing` to `production`. After each edit, **commit and push**, then refresh either version's -page — the Versions card and v2's own About card should reflect the new value within Backstage's -normal catalog refresh interval, while `museum-api-v1` stays unaffected throughout. +Edit `apis/museum-v2/museum-v2-openapi.yaml`'s `info.x-examplecorp.lifecycle`: change it from +`development` to `testing`, then from `testing` to `production`. Unlike Labs 2–5's `type: url` +catalog locations, `autoApiRegistration` reads these files directly off **local disk** — there is +nothing to commit or push. Save the file, wait for the next poll cycle (30 seconds by default), +and refresh either version's page: the Versions card and v2's own About card should reflect the +new value, while `museum-api-v1` stays unaffected throughout. ## Step 7 — Deprecate v1 -Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `deprecated`, then **commit and push**. Confirm the -Deprecated label appears on v1's own page **and** in the Versions card on both v1 and v2's pages. +Edit `apis/museum-v1/museum-v1-openapi.yaml`'s `info.x-examplecorp.lifecycle` to `deprecated`, save, +and wait for the next poll cycle. Confirm the Deprecated label appears on v1's own page **and** in +the Versions card on both v1 and v2's pages. ## Step 8 — Retire v1 -Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `retired`, then **commit and push**. Confirm: +Edit `apis/museum-v1/museum-v1-openapi.yaml`'s `info.x-examplecorp.lifecycle` to `retired`, save, and +wait for the next poll cycle. Confirm: - v1 disappears from the Versions card's default (expanded) list on both pages. - A "Show retired versions (1)" button appears; clicking it reveals v1, clearly labeled Retired. @@ -204,18 +231,53 @@ decision, not a data deletion. Backstage already has a native entity kind for "these things belong to one logical group": `System`. Setting `spec.system: museum-api` on every version gets you a real, already-indexed -relation — plus a free "Has part" list on the System's own page — with zero new backend code. A -bespoke annotation queried directly would still need a custom card to show anything, and would -forfeit that free System page for no benefit. See `research.md` R1 for the full comparison. +relation — plus a free "Has part" list on the System's own page — with zero new backend code +beyond what Lab 4 already built. A bespoke annotation queried directly would still need a custom +card to show anything, and would forfeit that free System page for no benefit. See `research.md` +R1 for the full comparison. + +Authoring `system.yaml` by hand would have worked too, but it would be one more hand-maintained +file per logical API, at odds with the whole reason Lab 4 exists. Instead, `autoApiRegistration.ts` +now synthesizes one `System` entity per distinct `x-examplecorp.apiBasename` value it discovers +across every spec in a source, the same way it already synthesizes `API` entities from spec +files — see `research.md` R1a. + +## Why Version Comes From `info.version`, Not an Annotation + +Every OpenAPI/AsyncAPI spec already has `info.version`. An earlier draft of this lab added a +parallel `apiportal.io/version` annotation to each catalog entity — but that's the same fact, +typed twice, with no mechanism keeping the two in sync if someone bumps one and not the other. +The Versions card instead parses `info.version` directly out of `spec.definition` (already a +plain, fully-resolved string by the time the catalog serves it, whether the entity came from +Lab 4's file-glob provider or a remote `$text` reference) — there's no copy to drift, because +there's no copy. See `research.md` R2. + +## Why Lifecycle Reuses the x-* Extension Field, Not a Catalog Override + +Lab 4's `autoApiRegistration.ts` already reads `info.x-.lifecycle` and puts it straight +into `spec.lifecycle` on the generated entity. An earlier draft of this lab instead hand-authored +`spec.lifecycle` directly in a catalog-info.yaml, silently overriding whatever the spec itself +declared — two sources of truth for the same fact, with the hand-authored one always winning +invisibly. This lab removes that override entirely: `x-examplecorp.lifecycle` in the spec file is +now the *only* place lifecycle state lives, and Steps 6–8 edit it there. See `research.md` R3. + +## Why apiBasename Drives System Assignment + +`apiBasename` is a new `x-examplecorp` field, read the same way `owner`/`lifecycle`/`visibility` +already are. It exists because "which logical API is this a version of" is metadata the API +producer should declare once, at the source, the same way they'd declare their own team as owner +— not something a catalog maintainer re-derives and hand-wires into a separate `System` file per +API family. `autoApiRegistration.ts` slugifies the value (`museum-api`) and uses it both as +`spec.system` on every matching `API` entity and as the `metadata.name` of a synthesized `System` +entity, deduplicated across every spec that shares it. See `research.md` R1a. ## Why Latest Is Computed, Not Hand-Flagged It would be simpler to add an `apiportal.io/latest: "true"` annotation to whichever version is newest. It would also be wrong the moment someone forgets to flip it when adding a third version — -two versions could both claim "latest," or none could. Instead, every version just carries its own -`apiportal.io/version` number, and the Versions card computes which one is highest (excluding -retired versions) every time it renders. There's no flag to get out of sync, because there's no -flag. +two versions could both claim "latest," or none could. Instead, every version's spec just carries +its own `info.version`, and the Versions card computes which one is highest (excluding retired +versions) every time it renders. There's no flag to get out of sync, because there's no flag. ## Why Backstage's Native Search Is Left Unmodified @@ -229,14 +291,20 @@ benefits from a curated view that only shows what's current. See `research.md` R ## Why Supersede Lab 2's Entry, Not Edit It -Lab 2 registered a single `museum-api` entity. Introducing "multiple major versions of the same -API" properly requires versioned names (`museum-api-v1`, `museum-api-v2`) — there's no way to keep -one entity unversioned while its sibling is `-v2` and still teach "these are equally-versioned -parallel entities." This lab's `app-config.yaml` change therefore removes Lab 2's original catalog -location and replaces it with three new ones. This is an explicitly-permitted "breaking change to -the environment" under this repository's constitution (Development Workflow section) — if you're -adapting this pattern to your own APIs, plan for the same kind of one-time migration the first time -you introduce explicit versioning to an API that previously had none. +Lab 2 registered a single `museum-api` entity via a hand-authored catalog-info.yaml. Introducing +"multiple major versions of the same API" properly requires versioned, auto-registered entities +(`museum-api-v1`, `museum-api-v2`) — there's no way to keep one entity unversioned while its +sibling is `-v2` and still teach "these are equally-versioned parallel entities." This lab +therefore removes Lab 2's original catalog location and, separately, adds a second +`autoApiRegistration` source pointing at this lab's own `apis/` directory. This is an +explicitly-permitted "breaking change to the environment" under this repository's constitution +(Development Workflow section) — if you're adapting this pattern to your own APIs, plan for the +same kind of one-time migration the first time you introduce explicit versioning to an API that +previously had none. + +`museum-v1-openapi.yaml` is a **local copy** of Lab 1's spec, not a `$text` reference to it, +specifically so Lab 4's file-glob provider (which only reads local files) can discover it without +requiring an edit to Lab 1's already-committed file — see the copy's own header comment. --- @@ -244,48 +312,55 @@ you introduce explicit versioning to an API that previously had none. Adaptable — change these freely for your own APIs: -- The `apiportal.io/version` annotation format (`"major.minor"`) — substitute full semver, a date, - or whatever versioning scheme your organization already uses. +- The `x-examplecorp` namespace name (rename to your own company/vendor identifier, same as Lab + 4's `xNamespace` — one namespace per organization, shared by every team and repo, not one per + tool that happens to read it) and its `owner`/`visibility`/`lifecycle`/`apiBasename` fields. - The five-value lifecycle convention (`development`/`testing`/`production`/`deprecated`/ `retired`) — `spec.lifecycle` accepts any string; use whatever states match your own process. -- "One `System` per logical API" — this is the pattern to replicate, not a fixed name; a real - catalog would have one `System` per API family, not just Museum's. +- "One `apiBasename` per logical API" — this is the pattern to replicate, not a fixed name; a real + catalog would have one `apiBasename` per API family, not just Museum's. Fixed — this is the mechanism itself, not a convention: -- The `System`/`spec.system` relation as the grouping mechanism. +- The `System`/`spec.system` relation as the grouping mechanism, and `autoApiRegistration.ts`'s + synthesis of one `System` per distinct `apiBasename`. - The `EntityCardBlueprint` pattern used to build the Versions card. +- Reading `info.version` directly rather than a parallel annotation. --- ## Verification -1. Both `museum-api-v1` and `museum-api-v2` appear in the catalog at the same time (Step 4). -2. The Versions card on either page lists both versions and flags v2 Latest (Step 4). -3. v1 and v2 show independent lifecycle chips that update independently as you edit them - (Steps 5–6). -4. Marking v1 deprecated shows the label everywhere v1 appears; marking it retired collapses it +1. Both `museum-api-v1` and `museum-api-v2` appear in the catalog at the same time, with no + hand-authored catalog-info.yaml for either (Step 4). +2. A `museum-api` System exists with no `system.yaml` ever authored (Step 4). +3. The Versions card on either page lists both versions and flags v2 Latest, computed from + `info.version` (Step 4). +4. v1 and v2 show independent lifecycle chips that update independently as you edit each spec's + `x-examplecorp.lifecycle` field, with no commit/push required (Steps 5–6). +5. Marking v1 deprecated shows the label everywhere v1 appears; marking it retired collapses it behind "Show retired versions" while it remains reachable via direct link (Steps 7–8). -5. `museum-api-v1`'s entity is never deleted — confirm it's still present at the end (Step 9). +6. `museum-api-v1`'s entity is never deleted — confirm it's still present at the end (Step 9). ## Troubleshooting -- **Versions card renders but shows only one version**: confirm both `museum-api-v1.yaml` and - `museum-api-v2.yaml` set `spec.system: museum-api` (not `museum-api-v1`/`v2` — that's a typo that - silently produces an empty sibling list, since the card queries by the exact `spec.system` - string). +- **Versions card renders but shows only one version**: confirm both spec files set the exact same + `x-examplecorp.apiBasename` value (`museum-api`) — a typo produces two different System slugs and + an empty sibling list on each side. +- **Neither museum API version appears at all**: check the backend logs for + `auto-api-registration:lab6-museum-api:` lines. A wrong `rootPath` in `app-config.yaml`'s + second `autoApiRegistration` source is the most common cause — it must resolve (relative to + `packages/backend`) to this lab's `apis/` directory. - **Old `museum-api` entity still shows up alongside the new versioned ones**: you likely forgot to remove Lab 2's original catalog location entry from `app-config.yaml` in Step 2 — the old and new entities can coexist harmlessly, but that defeats the "supersede, don't duplicate" point of this lab. -- **Lifecycle edit doesn't show up at all, no matter how long you wait**: you edited the local - file but didn't commit and push. These catalog locations are `type: url`, so Backstage reads - from your fork's GitHub remote, never your local disk — see the note above Step 6. -- **Lifecycle edit doesn't show up immediately after pushing**: catalog processing runs on its - normal refresh interval; give it a few seconds and refresh the page, or trigger an immediate - refresh via Backstage's `POST /api/catalog/refresh` endpoint with the entity's ref. -- **v2's spec fails to resolve (`$text` 404)**: if you're working from a fork, update the - `$text` URL in `museum-api-v2.yaml` to point at your fork's raw URL, not this repository's. +- **Lifecycle edit doesn't show up immediately**: `autoApiRegistration` polls on its configured + interval (30 seconds by default, see Lab 4's `schedule.frequencySeconds`) — give it a few + seconds and refresh the page. Unlike Labs 2–5, there is no git push step here. +- **"Owner ... does not resolve to a known User or Group entity"**: confirm `group:default/ + museum-team` is registered (Lab 2's `teams.yaml`) — the same check Lab 4 already performs for + every auto-registered API applies here too. --- @@ -295,3 +370,5 @@ Fixed — this is the mechanism itself, not a convention: decisions and alternatives considered for this lab. - [`data-model.md`](../../specs/006-api-lifecycle-management/data-model.md) — the exact entity shapes and the lifecycle state-transition diagram. +- [`labs/lab-04-auto-registration/README.md`](../lab-04-auto-registration/README.md) — the + auto-registration mechanism this lab extends. diff --git a/labs/lab-06-api-lifecycle-management/apis/museum-v1/museum-v1-openapi.yaml b/labs/lab-06-api-lifecycle-management/apis/museum-v1/museum-v1-openapi.yaml new file mode 100644 index 0000000..733de3b --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/apis/museum-v1/museum-v1-openapi.yaml @@ -0,0 +1,502 @@ +# Lab 6 — Museum API, version 1. +# +# This is a content-identical copy of Lab 1's museum openapi.yaml +# (labs/lab-01-base-backstage/apis/museum/openapi.yaml), kept as its own local file so Lab 4's +# file-glob autoApiRegistration provider (labs/lab-04-auto-registration/code/packages/backend/src/ +# extensions/autoApiRegistration.ts) can discover it — that provider only scans local files, and +# this repository's constitution/plan forbid editing Lab 1's already-committed file. The only +# addition is the `x-examplecorp` block below; every path, schema, and response is unchanged. See +# labs/lab-06-api-lifecycle-management/README.md for why v1 is auto-registered rather than +# hand-authored as a catalog-info.yaml. +openapi: 3.1.0 +info: + # Titled "Museum API v1", not just "Museum API" — autoApiRegistration.ts names each catalog + # entity from a slug of info.title (labs/lab-04-.../autoApiRegistration.ts's `slugify`), so two + # versions of the same logical API need distinguishable titles or they'd collide on one entity + # name. `apiBasename` below (not the title) is what actually says "these are the same API". + title: Museum API v1 + description: > + An API for getting information about the museum, its special events, and purchasing + tickets. This is a sample API used in the Backstage API Portal Lab to demonstrate + OpenAPI catalog registration. + version: 1.0.0 + contact: + name: Museum Support + email: support@example.museum + # Lab 6: read by autoApiRegistration.ts's x- extraction (source config below sets + # xNamespace: examplecorp). `apiBasename` groups every major version of this API under one + # System entity, synthesized automatically — no hand-authored system.yaml. `lifecycle` is the + # one field edited during the Step 6-8 walkthrough to advance/deprecate/retire this version. + x-examplecorp: + owner: group:default/museum-team + visibility: private + lifecycle: production + apiBasename: museum-api + +servers: + - url: https://api.example.museum/v1 + description: Production server + +tags: + - name: Operations + description: Museum operating information + - name: Events + description: Special events at the museum + - name: Tickets + description: Ticket purchasing and management + +paths: + /museum-hours: + get: + summary: Get museum hours + description: > + Returns the opening hours for the museum. Hours may vary on public holidays — + check the special events endpoint for closures. + operationId: getMuseumHours + tags: + - Operations + parameters: + - name: startDate + in: query + description: The start date to retrieve hours from (inclusive). Defaults to today. + schema: + type: string + format: date + example: "2024-02-01" + - name: page + in: query + description: The page number to retrieve. + schema: + type: integer + default: 1 + minimum: 1 + - name: limit + in: query + description: The number of days per page. + schema: + type: integer + default: 7 + minimum: 1 + maximum: 30 + responses: + "200": + description: Museum opening hours returned successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMuseumHoursResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /special-events: + get: + summary: List special events + description: > + Returns a paginated list of special events at the museum. Events are sorted by + start date, most recent first. + operationId: listSpecialEvents + tags: + - Events + parameters: + - name: startDate + in: query + schema: + type: string + format: date + - name: endDate + in: query + schema: + type: string + format: date + - name: page + in: query + schema: + type: integer + default: 1 + minimum: 1 + - name: limit + in: query + schema: + type: integer + default: 10 + minimum: 1 + maximum: 30 + responses: + "200": + description: List of special events returned successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/ListSpecialEventsResponse" + + post: + summary: Create a special event + description: > + Creates a new special event. The event is created in draft status and must be + published before it appears in the public listing. + operationId: createSpecialEvent + tags: + - Events + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSpecialEventRequest" + responses: + "201": + description: Special event created successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SpecialEventResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /special-events/{eventId}: + parameters: + - name: eventId + in: path + required: true + description: Unique identifier for the special event. + schema: + type: string + format: uuid + + get: + summary: Get a special event + operationId: getSpecialEvent + tags: + - Events + responses: + "200": + description: Special event details returned successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SpecialEventResponse" + "404": + $ref: "#/components/responses/NotFound" + + patch: + summary: Update a special event + operationId: updateSpecialEvent + tags: + - Events + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateSpecialEventRequest" + responses: + "200": + description: Special event updated successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SpecialEventResponse" + "404": + $ref: "#/components/responses/NotFound" + + delete: + summary: Delete a special event + operationId: deleteSpecialEvent + tags: + - Events + responses: + "204": + description: Special event deleted successfully. + "404": + $ref: "#/components/responses/NotFound" + + /special-events/{eventId}/publish: + post: + summary: Publish a special event + description: > + Publishes a draft special event, making it visible in the public listing. + Published events cannot be unpublished, only deleted. + operationId: publishSpecialEvent + tags: + - Events + parameters: + - name: eventId + in: path + required: true + schema: + type: string + format: uuid + responses: + "202": + description: Special event published successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/SpecialEventResponse" + "404": + $ref: "#/components/responses/NotFound" + + /tickets: + post: + summary: Buy museum tickets + description: > + Purchases one or more tickets for a museum visit or special event. Returns a + ticket ID that can be used to retrieve a QR code for entry. + operationId: buyMuseumTickets + tags: + - Tickets + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/BuyMuseumTicketsRequest" + responses: + "201": + description: Tickets purchased successfully. + content: + application/json: + schema: + $ref: "#/components/schemas/BuyMuseumTicketsResponse" + "400": + $ref: "#/components/responses/BadRequest" + + /tickets/{ticketId}/qr: + get: + summary: Get ticket QR code + description: > + Returns a QR code image for the specified ticket. Present this at the museum + entrance for admission. + operationId: getTicketQrCode + tags: + - Tickets + parameters: + - name: ticketId + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: QR code image returned successfully. + content: + image/png: + schema: + type: string + format: binary + "404": + $ref: "#/components/responses/NotFound" + +components: + schemas: + MuseumDayHours: + type: object + description: Opening hours for a single day. + properties: + date: + type: string + format: date + description: The date these hours apply to. + example: "2024-02-01" + timeOpen: + type: string + description: Opening time (24-hour format). + example: "09:00" + timeClose: + type: string + description: Closing time (24-hour format). + example: "17:00" + required: + - date + - timeOpen + - timeClose + + GetMuseumHoursResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/MuseumDayHours" + + SpecialEventResponse: + type: object + description: A special event at the museum. + properties: + eventId: + type: string + format: uuid + description: Unique identifier for the event. + name: + type: string + description: The name of the event. + example: "Mermaid Legends" + location: + type: string + description: Where the event takes place within the museum. + example: "Section D" + eventDescription: + type: string + description: A description of the event. + dates: + type: array + description: Dates on which the event takes place. + items: + type: string + format: date + price: + type: number + format: float + description: Ticket price in USD. + example: 25.00 + status: + type: string + enum: [draft, published] + description: Publication status of the event. + required: + - eventId + - name + - location + - dates + - price + - status + + ListSpecialEventsResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/SpecialEventResponse" + pagination: + type: object + properties: + page: + type: integer + limit: + type: integer + total: + type: integer + + CreateSpecialEventRequest: + type: object + required: + - name + - location + - dates + - price + properties: + name: + type: string + example: "Mermaid Legends" + location: + type: string + example: "Section D" + eventDescription: + type: string + dates: + type: array + items: + type: string + format: date + price: + type: number + format: float + example: 25.00 + + UpdateSpecialEventRequest: + type: object + properties: + name: + type: string + location: + type: string + eventDescription: + type: string + dates: + type: array + items: + type: string + format: date + price: + type: number + format: float + + BuyMuseumTicketsRequest: + type: object + required: + - ticketType + - ticketDate + - email + properties: + ticketType: + type: string + enum: [general, special] + description: Type of ticket — general admission or a special event. + ticketDate: + type: string + format: date + description: The date the ticket is valid for. + eventId: + type: string + format: uuid + description: Required when ticketType is "special". + email: + type: string + format: email + description: Email address to send the ticket confirmation to. + + BuyMuseumTicketsResponse: + type: object + properties: + ticketId: + type: string + format: uuid + description: Unique ticket identifier. Use this to retrieve the QR code. + ticketType: + type: string + enum: [general, special] + ticketDate: + type: string + format: date + confirmationCode: + type: string + example: "ticket-ABC123" + required: + - ticketId + - ticketType + - ticketDate + - confirmationCode + + Error: + type: object + properties: + type: + type: string + format: uri + title: + type: string + status: + type: integer + detail: + type: string + required: + - type + - title + - status + + responses: + BadRequest: + description: Bad request — invalid input parameters. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Error" + + NotFound: + description: Resource not found. + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Error" diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml b/labs/lab-06-api-lifecycle-management/apis/museum-v2/museum-v2-openapi.yaml similarity index 93% rename from labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml rename to labs/lab-06-api-lifecycle-management/apis/museum-v2/museum-v2-openapi.yaml index a462e74..6482d82 100644 --- a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml +++ b/labs/lab-06-api-lifecycle-management/apis/museum-v2/museum-v2-openapi.yaml @@ -1,6 +1,12 @@ +# Lab 6 — Museum API, version 2. A new major version, registered alongside (not replacing) v1 — +# see labs/lab-06-api-lifecycle-management/README.md. Discovered by Lab 4's file-glob +# autoApiRegistration provider like v1; no hand-authored catalog-info.yaml. openapi: 3.1.0 info: - title: Museum API + # Titled "Museum API v2", not just "Museum API" — see museum-v1-openapi.yaml's info.title + # comment for why (entity names are slugified from info.title, so sibling versions need + # distinguishable titles; `apiBasename` below is what actually says "same logical API"). + title: Museum API v2 description: > An API for getting information about the museum, its special events, and purchasing tickets. This is a sample API used in the Backstage API Portal Lab to demonstrate @@ -18,6 +24,13 @@ info: contact: name: Museum Support email: support@example.museum + # Lab 6: read by autoApiRegistration.ts's x- extraction (source config sets + # xNamespace: examplecorp). Same apiBasename as v1 groups both under one auto-synthesized System. + x-examplecorp: + owner: group:default/museum-team + visibility: private + lifecycle: development + apiBasename: museum-api servers: - url: https://api.example.museum/v2 diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml deleted file mode 100644 index 5b12762..0000000 --- a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v1.yaml +++ /dev/null @@ -1,32 +0,0 @@ -# Lab 6 — Museum API, version 1. -# -# Supersedes Lab 2's single `museum-api` catalog entry (labs/lab-02-users-roles/catalog/apis/ -# museum-api.yaml) — see labs/lab-06-api-lifecycle-management/README.md for why this lab -# introduces explicitly-versioned entity names instead of continuing to evolve one unversioned -# entity. The referenced OpenAPI file itself is untouched — this is a new catalog descriptor -# only, pointing at the same spec Lab 1 already registered. - -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: museum-api-v1 - description: > - A sample REST API for a fictional museum, demonstrating modern OpenAPI 3.1 design - practices. Covers special events, museum hours, and ticket purchasing. Version 1 — - see museum-api-v2 for the next major version and its breaking changes - tags: - - rest - - openapi - - sample - annotations: - # Same visibility mechanism introduced in Lab 2 — unchanged, applies per version. - example.com/visibility: private - # Lab 6: major.minor version identifier, used to sort versions and compute "latest". - apiportal.io/version: "1.0" -spec: - type: openapi - lifecycle: deprecated - owner: group:default/museum-team - system: museum-api - definition: - $text: https://raw.githubusercontent.com/DawMatt/backstage-apiportal-lab/main/labs/lab-01-base-backstage/apis/museum/openapi.yaml diff --git a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml b/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml deleted file mode 100644 index 7cd0248..0000000 --- a/labs/lab-06-api-lifecycle-management/catalog/apis/museum-api-v2.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# Lab 6 — Museum API, version 2. -# -# A new major version of the Museum API, registered alongside (not replacing) v1. Its spec -# contains deliberate breaking changes vs. v1 — see catalog/apis/museum-v2/openapi.yaml's -# info.description for the exact changelog. Starts life at lifecycle: development, and is -# advanced through testing -> production during the lab walkthrough (README Step 6). - -apiVersion: backstage.io/v1alpha1 -kind: API -metadata: - name: museum-api-v2 - description: > - Museum API — version 2. Introduces breaking changes to special-event identifiers and - the ticket QR-code endpoint; see catalog/apis/museum-v2/openapi.yaml's changelog. - tags: - - rest - - openapi - - sample - annotations: - example.com/visibility: private - # Lab 6: major.minor version identifier, used to sort versions and compute "latest". - apiportal.io/version: "2.0" -spec: - type: openapi - lifecycle: production - owner: group:default/museum-team - system: museum-api - definition: - # NOTE: points at this feature branch, not main, because this spec file doesn't exist on - # main yet — same convention as Lab 4's precedence-demo entry. Update to main once merged. - $text: https://raw.githubusercontent.com/DawMatt/backstage-apiportal-lab/006-api-lifecycle-management/labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml diff --git a/labs/lab-06-api-lifecycle-management/catalog/system.yaml b/labs/lab-06-api-lifecycle-management/catalog/system.yaml deleted file mode 100644 index 5aff5f1..0000000 --- a/labs/lab-06-api-lifecycle-management/catalog/system.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Lab 6 — Logical grouping for all major versions of the Museum API. -# -# A System entity is the native Backstage relation used to say "these API entities are all -# versions of the same logical API" (see labs/lab-06-api-lifecycle-management/README.md for why). -# Non-API entity kinds (including System) remain visible to all authenticated users regardless of -# an API's own visibility annotation — see the permission policy's Rule 1 from Lab 2. - -apiVersion: backstage.io/v1alpha1 -kind: System -metadata: - name: museum-api - description: > - Logical grouping of all major versions of the Museum API. Each major version is - registered as its own API entity (museum-api-v1, museum-api-v2, ...) and linked here - via spec.system, so they can coexist in the catalog while still being presented as - versions of one logical API. -spec: - owner: group:default/museum-team diff --git a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx index c427f84..18182e1 100644 --- a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx @@ -15,7 +15,7 @@ import { compareVersions, findLatest, getLifecycle, - getVersion, + getVersionString, isRetired, useApiSiblings, } from './versionUtils'; @@ -29,7 +29,7 @@ function VersionRow({ isCurrent: boolean; isLatest: boolean; }) { - const [major, minor] = getVersion(sibling); + const version = getVersionString(sibling) ?? 'unknown'; const lifecycle = getLifecycle(sibling); return ( @@ -42,11 +42,11 @@ function VersionRow({ > {isCurrent ? ( - {sibling.metadata.name} — v{major}.{minor} (this page) + {sibling.metadata.name} — v{version} (this page) ) : ( - {sibling.metadata.name} — v{major}.{minor} + {sibling.metadata.name} — v{version} )} {isLatest && } diff --git a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts index 3e69ed6..1a7314f 100644 --- a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts @@ -2,15 +2,33 @@ import { useEffect, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Entity } from '@backstage/catalog-model'; +import yaml from 'js-yaml'; -export const VERSION_ANNOTATION = 'apiportal.io/version'; export const RETIRED = 'retired'; export const DEPRECATED = 'deprecated'; -export function getVersion(entity: Entity): [number, number] { - const raw = entity.metadata.annotations?.[VERSION_ANNOTATION]; - const [major, minor] = (raw ?? '0.0').split('.').map(Number); - return [major || 0, minor || 0]; +// Version comes straight from the spec's own info.version — never a hand-authored annotation +// duplicating it. By the time the catalog serves an entity, spec.definition's `$text` (if any) +// has already been resolved to a plain string by Backstage's own placeholder processor, so this +// works identically whether the entity was auto-registered (Lab 4) or points at a remote spec. +export function getVersionString(entity: Entity): string | undefined { + const definition = entity.spec?.definition; + if (typeof definition !== 'string') { + return undefined; + } + try { + const doc = yaml.load(definition) as { info?: { version?: unknown } } | undefined; + const version = doc?.info?.version; + return typeof version === 'string' ? version : undefined; + } catch { + return undefined; + } +} + +export function getVersion(entity: Entity): [number, number, number] { + const raw = getVersionString(entity) ?? '0.0.0'; + const [major, minor, patch] = raw.split('.').map(Number); + return [major || 0, minor || 0, patch || 0]; } export function isRetired(entity: Entity): boolean { @@ -22,15 +40,15 @@ export function getLifecycle(entity: Entity): string | undefined { } export function compareVersions(a: Entity, b: Entity): number { - const [aMajor, aMinor] = getVersion(a); - const [bMajor, bMinor] = getVersion(b); - return aMajor !== bMajor ? aMajor - bMajor : aMinor - bMinor; + const [aMajor, aMinor, aPatch] = getVersion(a); + const [bMajor, bMinor, bPatch] = getVersion(b); + return aMajor !== bMajor ? aMajor - bMajor : aMinor !== bMinor ? aMinor - bMinor : aPatch - bPatch; } -// "Latest" is computed from every sibling's version annotation, not manually flagged, to -// avoid two versions ever claiming "latest" at once (research.md R2). Prefer the highest -// non-retired version; if every sibling is retired, fall back to the highest overall -// (spec.md edge case: no current version at all). +// "Latest" is computed from every sibling's info.version, not manually flagged, to avoid two +// versions ever claiming "latest" at once (research.md R2). Prefer the highest non-retired +// version; if every sibling is retired, fall back to the highest overall (spec.md edge case: no +// current version at all). export function findLatest(siblings: Entity[]): Entity | undefined { const active = siblings.filter(s => !isRetired(s)); const pool = active.length > 0 ? active : siblings; diff --git a/specs/006-api-lifecycle-management/checklists/issues.md b/specs/006-api-lifecycle-management/checklists/issues.md index e3f0645..c496a2f 100644 --- a/specs/006-api-lifecycle-management/checklists/issues.md +++ b/specs/006-api-lifecycle-management/checklists/issues.md @@ -1,2 +1,12 @@ ## Run 1 - 2026/07/05 +- [ ] Step 4 verification failed at the following steps: + +```markdown +- A `museum-api` `System` entity exists, listing both as "Has part" APIs, even though no + `system.yaml` was ever authored — it was synthesized from both specs' matching + `x-examplecorp.apiBasename: museum-api`. +- Opening either version's page shows the new "API Versions" card, listing both versions with + working links between them, and `museum-api-v2` flagged **Latest** (computed from each spec's + `info.version`, `1.0.0` vs. `2.0.0`). +``` \ No newline at end of file diff --git a/specs/006-api-lifecycle-management/data-model.md b/specs/006-api-lifecycle-management/data-model.md index e93cd4d..09faf84 100644 --- a/specs/006-api-lifecycle-management/data-model.md +++ b/specs/006-api-lifecycle-management/data-model.md @@ -1,87 +1,88 @@ # Data Model: Lab 6 — API Lifecycle Management -No database or persisted application state is introduced. All "data" in this lab is Backstage -catalog entity metadata (YAML), read at request time by one new read-only frontend card. This -document describes the shape of that catalog metadata and the in-memory view the frontend -constructs from it — not schema for a datastore. +No database or persisted application state beyond Lab 4's own scan-state cache (extended by one +nullable column, `system_slug`) is introduced. All "data" in this lab is OpenAPI spec metadata, +turned into Backstage catalog entities by Lab 4's `autoApiRegistration.ts` provider, and read at +request time by one new read-only frontend card. This document describes the shape of that spec +metadata, the entities the provider derives from it, and the in-memory view the frontend +constructs — not schema for a datastore. -## Entity: System (logical API grouping) - -New catalog kind used for the first time in this repository (already allowed by -`catalog.rules: allow: [..., System, ...]` in `app-config.yaml`, unused until now). +## Source: OpenAPI spec `info` block (one per major version) ```yaml -apiVersion: backstage.io/v1alpha1 -kind: System -metadata: - name: museum-api +openapi: 3.1.0 +info: + title: Museum API v1 description: > - Logical grouping of all major versions of the Museum API. -spec: - owner: group:default/museum-team + Museum API — version 1. Covers special events, museum hours, and ticket purchasing. + version: 1.0.0 + x-examplecorp: + owner: group:default/museum-team + visibility: private + lifecycle: production # or: development | testing | deprecated | retired + apiBasename: museum-api ``` -- **name**: The logical API's stable identity, independent of any version number. -- **owner**: Same owning team as its version entities (Lab 2's `museum-team`), consistent with - "visibility of other catalog entry types remains allow all" (System pages are open to everyone - regardless of any version's private/shared visibility, per the existing permission policy's - Rule 1 — non-API kinds are always visible). - -## Entity: API (one per major version) +- **`info.title`**: Must be distinguishable per version (`"Museum API v1"`, not just `"Museum + API"`) — `autoApiRegistration.ts` slugifies this into the entity name, so identical titles + across versions would collide on one entity. +- **`info.version`**: Read directly by the frontend Versions card; never duplicated into a catalog + annotation (research.md R2). +- **`x-examplecorp.owner`/`visibility`**: Same fields/extraction mechanism Lab 4 already built. +- **`x-examplecorp.lifecycle`**: New use of an existing extraction path — copied onto the generated + entity's `spec.lifecycle` (research.md R3). This is the field Steps 6–8 edit. +- **`x-examplecorp.apiBasename`**: New field, read the same way. Slugified and used both as + `spec.system` on the generated `API` entity and as the `metadata.name` of a synthesized `System` + entity (research.md R1a). -Existing kind, extended with two new pieces of metadata (an annotation and the `spec.system` -relation); `spec.lifecycle` is Backstage's existing native field, not a new one. +## Generated entity: API (one per major version, no catalog-info.yaml) ```yaml apiVersion: backstage.io/v1alpha1 kind: API metadata: - name: museum-api-v1 - description: > - Museum API — version 1. Covers special events, museum hours, and ticket purchasing. - tags: [rest, openapi, sample] + name: museum-api-v1 # slugified from info.title annotations: - example.com/visibility: private # unchanged from Lab 2 - apiportal.io/version: "1.0" # new: major.minor version identifier + example.com/visibility: private + apiportal-lab.io/managed-by: auto-api-registration:lab6-museum-api spec: type: openapi - lifecycle: production # or: development | testing | deprecated | retired + lifecycle: production # copied from info.x-examplecorp.lifecycle owner: group:default/museum-team - system: museum-api # new: relation to the System above - definition: - $text: https://raw.githubusercontent.com/DawMatt/backstage-apiportal-lab/main/labs/lab-01-base-backstage/apis/museum/openapi.yaml + system: museum-api # slugified from info.x-examplecorp.apiBasename + definition: ``` +Generated by `autoApiRegistration.ts`'s `buildEntity()` — no field here is hand-authored anywhere; +every value traces back to the spec file in the previous section. + +## Generated entity: System (one per distinct `apiBasename`, no `system.yaml`) + ```yaml apiVersion: backstage.io/v1alpha1 -kind: API +kind: System metadata: - name: museum-api-v2 - description: > - Museum API — version 2. Introduces breaking changes to special-event identifiers and the - ticket QR-code endpoint; see the lab README's changelog callout. - tags: [rest, openapi, sample] + name: museum-api annotations: - example.com/visibility: private - apiportal.io/version: "2.0" + apiportal-lab.io/managed-by: auto-api-registration:lab6-museum-api spec: - type: openapi - lifecycle: development # advanced through testing → production during the lab - owner: group:default/museum-team - system: museum-api - definition: - $text: https://raw.githubusercontent.com/DawMatt/backstage-apiportal-lab/main/labs/lab-06-api-lifecycle-management/apis/museum-v2/openapi.yaml + owner: group:default/museum-team # the source's defaultOwner ``` +Synthesized by `autoApiRegistration.ts`'s `buildSystemEntity()`, deduplicated per cycle across +every file in the source that shares the same `apiBasename` (research.md R1a). Visible to all +authenticated users regardless of any version's own visibility, per the existing permission +policy's Rule 1 — non-API kinds are always visible. + ### Fields | Field | Source | Notes | |---|---|---| -| `metadata.name` | New per version (`-v`) | Catalog-unique; the mechanism that makes "multiple versions" representable at all (FR-001). | -| `metadata.annotations['apiportal.io/version']` | New | `"."` string; sort key and "latest" computation input (research.md R2). Lab-defined convention — learners may rename or extend to full semver. | -| `spec.lifecycle` | Existing native field | Free-form string; convention values for this lab: `development`, `testing`, `production`, `deprecated`, `retired` (research.md R3). | -| `spec.system` | Existing native field, first used here | Relation to the shared `System` entity; the grouping mechanism (research.md R1). | -| `spec.owner` / `example.com/visibility` | Unchanged from Lab 2 | Inherited per-version, independent of every other version (FR-012). | +| `metadata.name` (API) | Slugified `info.title` | Catalog-unique; requires per-version titles (`"Museum API v1"`, not `"Museum API"`). | +| `spec.lifecycle` | `info.x-examplecorp.lifecycle` | Free-form string; convention values for this lab: `development`, `testing`, `production`, `deprecated`, `retired` (research.md R3). | +| `spec.system` | Slugified `info.x-examplecorp.apiBasename` | Relation to the synthesized `System` entity (research.md R1, R1a). | +| `spec.owner` / `example.com/visibility` | `info.x-examplecorp.owner`/`visibility` | Inherited per-version, independent of every other version (FR-012). | +| `System.metadata.name` | Slugified `info.x-examplecorp.apiBasename` | One per distinct value across every spec in the source, not one per file. | ## Derived (non-persisted) view: Version list entry @@ -91,7 +92,7 @@ entity's `spec.system` — not stored anywhere. | Field | Derivation | |---|---| | `name` | `metadata.name` | -| `version` | `metadata.annotations['apiportal.io/version']` | +| `version` | Parsed from `spec.definition`'s `info.version` (research.md R2) — never a catalog annotation | | `lifecycle` | `spec.lifecycle` | | `isLatest` | `true` for the sibling with the numerically highest `version` among those whose `lifecycle !== 'retired'`; if every sibling is `retired`, the highest overall is flagged instead (edge case from spec.md) | | `isRetired` | `lifecycle === 'retired'` — controls default collapse behavior (research.md R4) | @@ -103,8 +104,10 @@ entity's `spec.system` — not stored anywhere. development → testing → production → deprecated → retired ``` -- Each arrow is a one-line YAML edit to that version's own file (research.md R7); no enforcement - of ordering exists in Backstage itself — the lab documents the intended progression, it is not +- Each arrow is a one-line edit to that version's own **spec file's** `x-examplecorp.lifecycle` + field (research.md R3, R7), picked up by `autoApiRegistration.ts`'s existing poll/watch cycle — + no catalog YAML to edit, no commit/push required (the spec files live on local disk). No + enforcement of ordering exists — the lab documents the intended progression, it is not machine-validated. A version may also skip states (e.g. `development` → `deprecated`) if a learner chooses to; this is not blocked, consistent with Backstage's own lack of a lifecycle state machine. diff --git a/specs/006-api-lifecycle-management/plan.md b/specs/006-api-lifecycle-management/plan.md index effa498..4e70b81 100644 --- a/specs/006-api-lifecycle-management/plan.md +++ b/specs/006-api-lifecycle-management/plan.md @@ -9,26 +9,32 @@ Lab 6 builds on the running Backstage 1.51.0 instance from Labs 1–5. It demonstrates multiple major versions of the same logical API coexisting in the catalog, a default browsing experience that surfaces the latest version while keeping older ones reachable, independent per-version -lifecycle states, and a two-step deprecate-then-retire progression — using **only native -Backstage catalog relations/fields plus one new read-only frontend card**, with no new backend -module. Two `API` entities, `museum-api-v1` (Lab 1's unmodified Museum API, re-registered under a -versioned name) and `museum-api-v2` (a new spec with deliberate breaking changes), are grouped -under one new `System` entity (`system:default/museum-api`) via the native `spec.system` relation +lifecycle states, and a two-step deprecate-then-retire progression — using **native Backstage +catalog relations/fields, one read-only frontend card, and an additive extension to Lab 4's +existing `autoApiRegistration.ts` backend module**, with zero hand-authored catalog YAML. Two +`API` entities, `museum-api-v1` (a local, content-identical copy of Lab 1's unmodified Museum API +spec) and `museum-api-v2` (a new spec with deliberate breaking changes), are both auto-registered +by a second `autoApiRegistration` source pointing at this lab's own `apis/` directory, and grouped +under one `System` entity (`system:default/museum-api`) via the native `spec.system` relation (research.md R1) — superseding Lab 2's single `museum-api` catalog entry (research.md R6, an explicitly-permitted "breaking change to the environment" per the constitution's Development -Workflow section). A new `apiportal.io/version` annotation drives a *computed* (not manually -flagged) "latest version" determination (research.md R2). Lifecycle state reuses Backstage's -existing free-form `spec.lifecycle` field — already rendered natively — extended with a -documented five-value convention (`development`/`testing`/`production`/`deprecated`/`retired`, -research.md R3). A new frontend module, `apiVersions` (`packages/app/src/modules/apiVersions/`), -follows the exact `EntityCardBlueprint` pattern already used by Lab 2's `apiVisibility` and Lab -3's `apiGrade`: it lists every sibling version of the API being viewed, flags the latest, and -collapses `retired` versions behind a "Show retired versions" toggle, closed by default -(research.md R4). Backstage's built-in full-text search/catalog table is deliberately left -unmodified — the Versions card is the taught "default" discovery surface, a documented design -boundary rather than a scaling gap (research.md R5). All lifecycle transitions, including -retirement, are one-line `spec.lifecycle` edits picked up via existing catalog polling — no new -mutation endpoint or UI action (research.md R7). No entity is ever deleted (FR-009). +Workflow section). The `System` itself is synthesized by the extended provider from a new +`info.x-examplecorp.apiBasename` field each spec declares (research.md R1a) — no `system.yaml` is +authored. "Latest version" is read directly from each spec's own `info.version` and computed, not +manually flagged (research.md R2) — no `apiportal.io/version` annotation is added. Lifecycle state +reuses Backstage's existing free-form `spec.lifecycle` field, populated from each spec's own +`info.x-examplecorp.lifecycle` extension field via the same extraction mechanism Lab 4 already built +for owner/visibility (research.md R3), with a documented five-value convention +(`development`/`testing`/`production`/`deprecated`/`retired`). A new frontend module, `apiVersions` +(`packages/app/src/modules/apiVersions/`), follows the exact `EntityCardBlueprint` pattern already +used by Lab 2's `apiVisibility` and Lab 3's `apiGrade`: it lists every sibling version of the API +being viewed, flags the latest, and collapses `retired` versions behind a "Show retired versions" +toggle, closed by default (research.md R4). Backstage's built-in full-text search/catalog table is +deliberately left unmodified — the Versions card is the taught "default" discovery surface, a +documented design boundary rather than a scaling gap (research.md R5). All lifecycle transitions, +including retirement, are one-line edits to a spec file's own `x-examplecorp.lifecycle` field, picked +up via Lab 4's existing poll/watch cycle — no commit/push, no new mutation endpoint, no new UI +action (research.md R7). No entity is ever deleted (FR-009). ## Technical Context @@ -36,13 +42,21 @@ mutation endpoint or UI action (research.md R7). No entity is ever deleted (FR-0 **Primary Dependencies**: None new. Reuses `@backstage/plugin-catalog-react/alpha`'s `EntityCardBlueprint` (already a dependency, used by Lab 2's `apiVisibility` and Lab 3's -`apiGrade`) and the existing `catalogApiRef` (`useApi(catalogApiRef).getEntities()`) already used -elsewhere in the Backstage frontend. No new root or `packages/app` dependency is added. - -**Storage**: No database or generated config. All new state is committed catalog YAML (one -`System` entity, two `API` entities, one new OpenAPI spec file) plus a `catalog.locations` update -in `app-config.yaml` (data-model.md). No runtime-generated files, consistent with Labs 1–4 (Lab -5's mock-gateway is the only lab with a generated-artifact concern, and this lab has none). +`apiGrade`), the existing `catalogApiRef` (`useApi(catalogApiRef).getEntities()`) already used +elsewhere in the Backstage frontend, and `js-yaml` (already a `packages/app` dependency as of Lab +5's `apiMocking` module) to parse `info.version` out of `spec.definition` client-side. No new root +or `packages/app` dependency is added. On the backend, this lab makes one additive, backward- +compatible extension to Lab 4's existing `autoApiRegistration.ts` module (new `apiBasename` +extraction, `spec.system` assignment, and `System` entity synthesis) rather than adding a new +module — existing Lab 4 sources/specs with no `apiBasename` are unaffected. + +**Storage**: No new database beyond Lab 4's own scan-state cache, extended by one nullable column +(`system_slug`, migration `002_add_system_slug`) so the extended provider can recompute which +`System` entities a source currently implies without re-parsing every file each cycle. All new +*catalog* state comes entirely from two local OpenAPI spec files (data-model.md) plus a second +`autoApiRegistration` source and a `catalog.locations` removal in `app-config.yaml` — zero +hand-authored catalog YAML. No runtime-generated files, consistent with Labs 1–4 (Lab 5's +mock-gateway is the only lab with a generated-artifact concern, and this lab has none). **Testing**: Manual browser verification per lab README (no automated test suite — consistent with Labs 1–5's tutorial-lab testing approach; see quickstart.md for the exact verification @@ -50,8 +64,9 @@ walkthrough). **Target Platform**: Local development machine — Windows 10/11 and macOS 12+ (same as Labs 1–5). -**Project Type**: Tutorial lab — Markdown documentation + catalog YAML + one new OpenAPI spec -file + one new small TypeScript frontend module (an `EntityCardBlueprint` info card). +**Project Type**: Tutorial lab — Markdown documentation + two OpenAPI spec files (no catalog YAML) ++ one new small TypeScript frontend module (an `EntityCardBlueprint` info card) + one additive +extension to Lab 4's existing backend module. **Performance Goals**: The Versions card's `catalogApi.getEntities()` call is filtered server-side to the viewed entity's own `spec.system`, so it returns only that API's sibling @@ -59,16 +74,23 @@ versions (typically single digits) regardless of total catalog size — O(1) rel scale, not O(total APIs) (research.md R8). **Constraints**: Zero cost; cross-platform; no external network access beyond what Labs 1–5 -already require; supersedes Lab 2's single `museum-api` catalog entry (research.md R6) but edits -no other previously-committed spec/catalog file, and does not require Labs 1–5 to be redone -(FR-011). - -**Scale/Scope**: Lab demo: 1 new `System` entity, 2 new `API` entities (v1 re-registered -unmodified, v2 new with deliberate breaking changes), 1 new OpenAPI spec file, 1 new frontend -module (~1-2 files), 1 `app-config.yaml` `catalog.locations` edit (3 entries replacing 1), 0 new -dependencies, 0 new backend modules. Designed-for scale (config-only, per research.md R8): each -additional logical API at a 500+-API deployment is one more `System` + N more `API` YAML files, -with no code or architecture change and no growth in the Versions card's per-view query cost. +already require; supersedes Lab 2's single `museum-api` catalog entry (research.md R6); does not +edit any previously-committed **spec** file (v1's spec is a new local copy, not an edit to Lab 1's +file — research.md R6) and does not require Labs 1–5 to be redone (FR-011). The one deliberate +exception to "no edits to previously-committed files" is `autoApiRegistration.ts` itself +(research.md R1a) — an additive, backward-compatible change, not a rewrite, made because +`apiBasename`→`System` support has no other implementation surface consistent with Lab 4's own +"metadata declared once, at the source" principle. + +**Scale/Scope**: Lab demo: 2 new `API` entities (v1 a local, content-identical copy of Lab 1's +spec, v2 new with deliberate breaking changes), both auto-registered; 1 `System` entity, +auto-synthesized from `apiBasename` (no `system.yaml`); 1 new frontend module (~3 files); 1 +`app-config.yaml` change (a second `autoApiRegistration` source added, 1 `catalog.locations` entry +removed, 0 added); 1 additive extension to Lab 4's existing backend module (`apiBasename` +extraction + `System` synthesis + 1 new nullable cache column); 0 new dependencies; 0 new backend +modules. Designed-for scale (config-only, per research.md R8): each additional logical API at a +500+-API deployment is one more spec file declaring an `apiBasename`, with no code or architecture +change and no growth in the Versions card's per-view query cost. ## Constitution Check @@ -76,14 +98,14 @@ with no code or architecture change and no growth in the Versions card's per-vie | Principle | Gate | Status | |-----------|------|--------| -| I. Feature-Focused Learning | Lab demonstrates concrete, named Backstage features: the `System` entity/`spec.system` relation for grouping, the native free-form `spec.lifecycle` field for per-version state, and the `EntityCardBlueprint` pattern for a computed cross-version summary view | ✅ Pass | -| II. Process-Oriented Documentation | README must explain WHY `System` (not a bespoke annotation) was chosen for grouping (research.md R1), WHY "latest" is computed rather than manually flagged (research.md R2), WHY Backstage's native search/catalog table is deliberately left unmodified rather than built into a custom collator (research.md R5 — a concrete worked example of a documented design boundary, worth calling out explicitly to learners), and WHY Lab 2's single `museum-api` entity is being superseded by two versioned entities (research.md R6). The new `ApiVersionsCard.tsx` and the new v2 OpenAPI spec are each likely to exceed the ~40–50 line inline threshold and MUST be committed under `labs/lab-06-api-lifecycle-management/code/` and `.../apis/museum-v2/` respectively, linked from the README, not inlined | ✅ Pass (with file-size rule flagged for tasks) | -| III. Cross-Platform Compatibility | Purely catalog YAML edits, one static OpenAPI file, and a pure-TypeScript React card — no native/platform-specific dependencies; `yarn start` continues to work identically on Windows and macOS; no new scripts or platform-specific commands are introduced | ✅ Pass | -| IV. Self-Contained Prerequisites | No new prerequisites beyond Labs 1–5's existing Node/Yarn/Backstage setup — no new dependency to install | ✅ Pass | +| I. Feature-Focused Learning | Lab demonstrates concrete, named Backstage features: the `System` entity/`spec.system` relation for grouping, Lab 4's `x-` extraction mechanism extended to a new field (`apiBasename`) and a new target (`spec.lifecycle`), and the `EntityCardBlueprint` pattern for a computed cross-version summary view | ✅ Pass | +| II. Process-Oriented Documentation | README must explain WHY `System` (not a bespoke annotation) was chosen for grouping (research.md R1), WHY it is auto-synthesized rather than hand-authored (research.md R1a), WHY "latest" is read from `info.version` rather than a duplicated annotation (research.md R2), WHY lifecycle reuses the spec's own `x-examplecorp.lifecycle` field rather than a catalog override (research.md R3), WHY Backstage's native search/catalog table is deliberately left unmodified rather than built into a custom collator (research.md R5), and WHY Lab 2's single `museum-api` entity is being superseded (research.md R6). The new `ApiVersionsCard.tsx`, the two OpenAPI spec files, and the `autoApiRegistration.ts` extension are each likely to exceed the ~40–50 line inline threshold and MUST be committed under `labs/lab-06-api-lifecycle-management/{code,apis}/` and `labs/lab-04-auto-registration/code/` respectively, linked from the README, not inlined | ✅ Pass (with file-size rule flagged for tasks) | +| III. Cross-Platform Compatibility | Two static OpenAPI files, an additive TypeScript change to an existing backend module, and a pure-TypeScript React card — no native/platform-specific dependencies; `yarn start` continues to work identically on Windows and macOS; no new scripts or platform-specific commands are introduced | ✅ Pass | +| IV. Self-Contained Prerequisites | No new prerequisites beyond Labs 1–5's existing Node/Yarn/Backstage setup — no new dependency to install (`js-yaml` is already present as of Lab 5) | ✅ Pass | | V. Zero-Cost Operation | No new dependency, tool, or service of any kind, paid or otherwise | ✅ Pass | -| VI. Progressive Lab Structure | Requires Labs 1–5 completion; reuses Lab 1's Museum OpenAPI spec unmodified for v1; the one deliberate exception (superseding Lab 2's single `museum-api` catalog entry, research.md R6) is an explicitly-permitted "breaking change to the environment," documented in README and this plan rather than left implicit | ✅ Pass | -| VII. Modern & Purposeful API Examples | v1 reuses the already-compliant Museum API unmodified; v2 is a deliberately-breaking variant of the same well-designed API (not a new placeholder example), authored specifically to make the version difference concrete | ✅ Pass | -| VIII. Support Experimentation & Scale | README documents adaptable conventions: the `apiportal.io/version` annotation format, the five lifecycle-value convention, and that "System per logical API" is the pattern to replicate for a learner's own APIs. Verification tests outcomes (which version is flagged latest, which lifecycle chip shows, whether a retired version is collapsed), not implementation internals. **Scale**: the System+relation+filtered-query mechanism is config-only to extend (research.md R8) — no code/architecture change between the lab's 2-version demo and a 500+-API deployment. The one explicitly-flagged design boundary (native search/catalog table left unmodified, research.md R5) is documented as a deliberate, generalizing choice rather than an implicit gap | ✅ Pass | +| VI. Progressive Lab Structure | Requires Labs 1–5 completion; v1's spec is a content-identical local copy of Lab 1's Museum OpenAPI spec (Lab 1's own committed file is never edited); the one deliberate exception (superseding Lab 2's single `museum-api` catalog entry, research.md R6) is an explicitly-permitted "breaking change to the environment," documented in README and this plan rather than left implicit | ✅ Pass | +| VII. Modern & Purposeful API Examples | v1 reuses the already-compliant Museum API's content unmodified (aside from the required `x-examplecorp` extension block and a disambiguating title); v2 is a deliberately-breaking variant of the same well-designed API (not a new placeholder example), authored specifically to make the version difference concrete | ✅ Pass | +| VIII. Support Experimentation & Scale | README documents adaptable conventions: the `x-examplecorp` namespace and its fields, the five lifecycle-value convention, and that "one `apiBasename` per logical API" is the pattern to replicate for a learner's own APIs. Verification tests outcomes (which version is flagged latest, which lifecycle chip shows, whether a retired version is collapsed), not implementation internals. **Scale**: the System+relation+filtered-query mechanism is config-only to extend (research.md R8) — no code/architecture change between the lab's 2-version demo and a 500+-API deployment, and adding a new logical API family needs no new backend code, only a spec file. The one explicitly-flagged design boundary (native search/catalog table left unmodified, research.md R5) is documented as a deliberate, generalizing choice rather than an implicit gap | ✅ Pass | | IX. Pragmatic Security for Learning Environments | N/A — no new credentials, secrets, or auth configuration are introduced by this lab (research.md R9); no Security Note section required | ✅ Pass (N/A, no new credential surface) | No violations. Complexity Tracking section omitted. @@ -102,41 +124,50 @@ specs/006-api-lifecycle-management/ ``` No `contracts/` directory — this lab produces no external API contracts of its own. The new -catalog entity shapes (`System`, versioned `API`, annotation) are documented in data-model.md -instead. +catalog entity shapes (auto-registered `API`, auto-synthesized `System`) are documented in +data-model.md instead. ### Source Code (repository root) ```text labs/ +├── lab-04-auto-registration/ +│ └── code/packages/backend/src/extensions/ +│ ├── autoApiRegistration.ts # Modified: additive apiBasename→System support +│ │ # (research.md R1a) — existing behavior for +│ │ # specs with no apiBasename is unchanged +│ └── autoApiRegistrationMigrations/ +│ └── 002_add_system_slug.ts # New: nullable system_slug cache column └── lab-06-api-lifecycle-management/ ├── README.md # Lab documentation (overview, prereqs, steps, │ # verification, troubleshooting, adaptable │ # conventions per Constitution Principle VIII; │ # no Security Note needed, research.md R9) - ├── catalog/ - │ ├── system.yaml # New: System entity (system:default/museum-api) - │ ├── apis/ - │ │ ├── museum-api-v1.yaml # New: v1 descriptor — supersedes Lab 2's single - │ │ │ # museum-api.yaml; references Lab 1's openapi.yaml - │ │ │ # unmodified (research.md R6) - │ │ └── museum-api-v2.yaml # New: v2 descriptor, references the new v2 spec - │ │ # below - │ └── apis/museum-v2/ - │ └── openapi.yaml # New: Museum API v2 with deliberate breaking - │ # changes vs. v1, documented as a changelog - │ # callout in the README (research.md R6) + ├── apis/ + │ ├── museum-v1/ + │ │ └── museum-v1-openapi.yaml # New: content-identical local copy of Lab 1's spec + │ │ # + x-examplecorp block (research.md R6) — no + │ │ # catalog-info.yaml + │ └── museum-v2/ + │ └── museum-v2-openapi.yaml # New: Museum API v2 with deliberate breaking + │ # changes vs. v1 + x-examplecorp block, documented + │ # as a changelog callout (research.md R6) └── code/ └── packages/ └── app/ └── src/ └── modules/ └── apiVersions/ - ├── ApiVersionsCard.tsx # New: lists sibling versions, flags - │ # latest, collapses retired ones - │ # (research.md R2, R4) - └── index.ts # New: EntityCardBlueprint wrapper, - # same pattern as apiVisibility/apiGrade + ├── ApiVersionsCard.tsx # New: lists sibling versions, flags + │ # latest from info.version, collapses + │ # retired ones (research.md R2, R4) + ├── ApiLifecycleBanner.tsx # New: main-column warning for + │ # deprecated/retired versions + ├── versionUtils.ts # New: shared version/lifecycle + │ # parsing, incl. info.version via + │ # js-yaml (research.md R2) + └── index.ts # New: EntityCardBlueprint wrapper, + # same pattern as apiVisibility/apiGrade ``` Changes to the student's Backstage instance (guided by README, not committed as generated @@ -144,9 +175,11 @@ output, but the module source itself IS committed since it is lab teaching conte ```text # In the student's Backstage instance (labs/lab-01-base-backstage/backstage/): -app-config.yaml # Modified: catalog.locations — remove Lab 2's - # single museum-api.yaml entry, add 3 new - # entries (system, museum-api-v1, museum-api-v2) +app-config.yaml # Modified: autoApiRegistration converted to the + # `sources:` array form with a new lab6-museum-api + # source; Lab 2's single museum-api.yaml + # catalog.locations entry removed, no replacement + # entries added (auto-registration needs none) packages/app/ └── src/ ├── App.tsx # Modified: register apiVersionsModule in the @@ -154,17 +187,21 @@ packages/app/ └── modules/ └── apiVersions/ # New: copied from labs/lab-06-.../code/packages/ ├── ApiVersionsCard.tsx # app/src/modules/apiVersions/ + ├── ApiLifecycleBanner.tsx + ├── versionUtils.ts └── index.ts ``` **Structure Decision**: Tutorial-documentation layout, consistent with Labs 1–5. All student-facing content lives under `labs/lab-06-api-lifecycle-management/`; the new frontend -module is committed under its `code/` subdirectory (exceeds the ~40–50 line inline threshold per -Constitution Principle II) and linked from the README rather than embedded inline. The student's -Backstage instance (from Lab 1) is modified in place per the README instructions, same pattern as -Labs 2–5. Lab 1's original Museum OpenAPI file is reused unmodified for v1; the new v2 spec and -all new catalog descriptors live under this lab's own directory, not Lab 1's or Lab 2's. Speckit -artefacts live under `specs/006-api-lifecycle-management/` per convention. +module and both OpenAPI spec files are committed under this lab's own directory (each exceeds the +~40–50 line inline threshold per Constitution Principle II) and linked from the README rather than +embedded inline. The one exception is the additive `autoApiRegistration.ts` extension, committed +under Lab 4's own `code/` directory since it modifies that lab's shared module, not a Lab 6-owned +file. The student's Backstage instance (from Lab 1) is modified in place per the README +instructions, same pattern as Labs 2–5. Lab 1's original Museum OpenAPI file is never edited — v1 +is a new, content-identical local copy living under this lab's own directory, not Lab 1's or Lab +2's. Speckit artefacts live under `specs/006-api-lifecycle-management/` per convention. ## Complexity Tracking diff --git a/specs/006-api-lifecycle-management/quickstart.md b/specs/006-api-lifecycle-management/quickstart.md index 9174787..78740db 100644 --- a/specs/006-api-lifecycle-management/quickstart.md +++ b/specs/006-api-lifecycle-management/quickstart.md @@ -11,36 +11,43 @@ for `tasks.md`'s acceptance-testing tasks, not new documentation prose in its ow ## Steps -1. Add the new `System` entity (`museum-api`) and two versioned `API` entities (`museum-api-v1`, - `museum-api-v2`) under `labs/lab-06-api-lifecycle-management/catalog/`, and the new `museum-v2` - OpenAPI spec file under `labs/lab-06-api-lifecycle-management/apis/museum-v2/openapi.yaml`. -2. Update `app-config.yaml`'s `catalog.locations`: remove Lab 2's single `museum-api.yaml` entry, - add the three new entries (System, v1, v2). -3. Add the `apiVersions` frontend module (`packages/app/src/modules/apiVersions/`) and register it +1. Add the two OpenAPI spec files under `labs/lab-06-api-lifecycle-management/apis/museum-v1/` and + `.../museum-v2/`, each with an `info.x-examplecorp` block declaring `owner`, `visibility`, + `lifecycle`, and `apiBasename: museum-api`. No catalog-info.yaml, and no `system.yaml`. +2. Extend `autoApiRegistration.ts` (`labs/lab-04-auto-registration/code/.../autoApiRegistration.ts`) + to read `x-.apiBasename`, set `spec.system` on generated `API` entities, and + synthesize one `System` entity per distinct `apiBasename`. +3. Update `app-config.yaml`: convert `autoApiRegistration` to the `sources:` array form and add a + second source (`lab6-museum-api`) pointing at this lab's `apis/` directory; remove Lab 2's + single `museum-api.yaml` `catalog.locations` entry (no replacement entries needed). +4. Add the `apiVersions` frontend module (`packages/app/src/modules/apiVersions/`) and register it in `packages/app/src/App.tsx`'s `features` array, following the same pattern as `apiVisibilityModule`/`apiGradeModule`. -4. Restart Backstage (`yarn start`). Confirm the catalog now shows `museum-api-v1` and - `museum-api-v2` as separate entities, and a new `museum-api` System entity. -5. Open either version's page. Confirm the new "API Versions" card lists both versions, flags v2 as - Latest, and shows each one's own lifecycle chip (v1: production, v2: development). -6. Edit `museum-api-v2`'s catalog file: change `spec.lifecycle` from `development` to `testing`, - then to `production`. After each change, refresh the page and confirm the Versions card and the - entity's own About card reflect the new value, and that v1's entry is unaffected. -7. Edit `museum-api-v1`'s catalog file: change `spec.lifecycle` to `deprecated`. Confirm the +5. Restart Backstage (`yarn start`). Within one poll cycle, confirm the catalog now shows + `museum-api-v1` and `museum-api-v2` as separate entities (no hand-authored catalog file for + either), and a `museum-api` System entity synthesized from their shared `apiBasename`. +6. Open either version's page. Confirm the new "API Versions" card lists both versions, flags v2 as + Latest (computed from each spec's own `info.version`), and shows each one's own lifecycle chip + (v1: production, v2: development, both read from `info.x-examplecorp.lifecycle`). +7. Edit `museum-v2-openapi.yaml`'s `info.x-examplecorp.lifecycle`: change it from `development` to + `testing`, then to `production`. After each change, wait for the next poll cycle, refresh the + page, and confirm the Versions card and the entity's own About card reflect the new value, and + that v1's entry is unaffected — no commit/push required, since the file is read from local disk. +8. Edit `museum-v1-openapi.yaml`'s `info.x-examplecorp.lifecycle` to `deprecated`. Confirm the Deprecated label appears on v1's own page and in the Versions card on both v1 and v2's pages. -8. Edit `museum-api-v1`'s catalog file again: change `spec.lifecycle` to `retired`. Confirm v1 now - only appears in the Versions card behind the "Show retired versions" toggle (collapsed by - default), while remaining fully viewable via a direct link to its own page, clearly labeled - Retired. -9. Confirm no catalog entity was deleted at any point in steps 6–8 — `museum-api-v1` is still - present and inspectable in the catalog at the end. +9. Edit `museum-v1-openapi.yaml`'s `info.x-examplecorp.lifecycle` again: change it to `retired`. + Confirm v1 now only appears in the Versions card behind the "Show retired versions" toggle + (collapsed by default), while remaining fully viewable via a direct link to its own page, + clearly labeled Retired. +10. Confirm no catalog entity was deleted at any point in steps 7–9 — `museum-api-v1` is still + present and inspectable in the catalog at the end. ## Success Criteria Mapping | Quickstart Step | Spec Success Criterion | |---|---| -| 4 | SC-001 | -| 5 | SC-002, SC-003 | -| 6 | SC-004 | -| 7–8 | SC-005 | -| 9 | SC-006 | +| 5 | SC-001 | +| 6 | SC-002, SC-003 | +| 7 | SC-004 | +| 8–9 | SC-005 | +| 10 | SC-006 | diff --git a/specs/006-api-lifecycle-management/research.md b/specs/006-api-lifecycle-management/research.md index 65ccfb2..5073704 100644 --- a/specs/006-api-lifecycle-management/research.md +++ b/specs/006-api-lifecycle-management/research.md @@ -3,15 +3,15 @@ ## R1: Mechanism for grouping multiple versions of "the same" API **Decision**: Use Backstage's native `System` entity kind (already allowed by `catalog.rules` in -`app-config.yaml`, unused until now) as the logical-API grouping mechanism. Create one `System` -per logical API (e.g. `system:default/museum-api`), and set `spec.system` on every version's `API` -entity to point at it. +`app-config.yaml`, unused until now) as the logical-API grouping mechanism. Set `spec.system` on +every version's `API` entity to point at one `System` per logical API (e.g. +`system:default/museum-api`). **Rationale**: `spec.system` is a first-class, already-indexed Backstage relation — no new backend code, plugin, or catalog processor is needed to establish "these entities are versions of the same API." The System's own page also gets a "Has part" APIs list for free. This is the most -scale-friendly option: adding a new logical API at real scale (500+ APIs) is one more `System` YAML -file, not a code or architecture change (Constitution Principle VIII). +scale-friendly option: adding a new logical API at real scale (500+ APIs) is one more spec file +declaring its `apiBasename` (R1a), not a code or architecture change (Constitution Principle VIII). **Alternatives considered**: - A bespoke annotation (e.g. `apiportal.io/api-family: museum-api`) queried directly, skipping @@ -23,42 +23,79 @@ file, not a code or architecture change (Constitution Principle VIII). entities," and scales better as more versions are added (each new API version just adds one `spec.system` line, rather than needing an ever-growing web of pairwise relations). -## R2: Identifying and displaying the "latest" version - -**Decision**: Add an annotation, `apiportal.io/version: "."` (e.g. `"1.0"`, `"2.0"`), -to every version's `API` entity. "Latest" is *computed*, not manually flagged: the new frontend -card (R4) fetches all `API` entities sharing the current entity's `spec.system`, parses each one's -`apiportal.io/version` annotation, and highlights whichever non-retired version has the highest -value. +## R1a: Creating the System without a hand-authored catalog file + +**Decision**: Extend Lab 4's `autoApiRegistration.ts` `EntityProvider` (additive, backward- +compatible change — existing Lab 4 specs with no `apiBasename` are unaffected) to read a new +`x-.apiBasename` field from each spec's `info` block, the same way it already reads +`owner`/`lifecycle`/`visibility`. The slugified value becomes `spec.system` on the generated `API` +entity, and the provider synthesizes exactly one `System` entity per distinct slug it observes +across a source's files — deduplicated every cycle from the scan-state cache, not re-derived from +scratch each time. + +**Rationale**: Lab 4 exists specifically so API metadata is declared once, at the source, instead +of hand-maintained in a parallel catalog file. Requiring a hand-authored `system.yaml` per logical +API would reintroduce exactly the duplicate-file problem Lab 4 eliminates for `API` entities, just +one level up. Since `apiBasename` is metadata the API producer already knows (same team that +declares `owner`), extending the existing extraction mechanism is more consistent than inventing a +second, System-specific configuration surface. + +**Alternatives considered**: A hand-authored `system.yaml` per logical API (this lab's original +design) — rejected once auto-registration was extended to cover both `API` versions, since it +would leave exactly one catalog YAML file as the sole exception to an otherwise fully +auto-registered lab. A dedicated "system discovery" backend module, separate from +`autoApiRegistration.ts` — rejected as unnecessary duplication; the file-glob/x-* extraction +mechanism is identical, only the target entity kind differs. -**Rationale**: A manually-maintained `apiportal.io/latest: "true"` flag could drift (two entities -both marked latest, or none) — computing it from a single, unambiguous version number is simpler to -keep correct and is the more honest lesson (derive facts from data, don't hand-maintain a flag that -duplicates it). +## R2: Identifying and displaying the "latest" version -**Alternatives considered**: A manual `is-latest` boolean annotation — rejected for the drift risk -above. Semantic-version libraries for full semver comparison — rejected as overkill; major.minor -string comparison via numeric parsing is sufficient for this lab's two-version demo and documented -as adaptable for real semver strings. +**Decision**: Read each version's `info.version` (a field every OpenAPI/AsyncAPI spec already has) +directly, rather than adding any new annotation. "Latest" is *computed*, not manually flagged: the +new frontend card (R4) fetches all `API` entities sharing the current entity's `spec.system`, +parses each sibling's `info.version` out of its (already placeholder-resolved) `spec.definition` +string, and highlights whichever non-retired version has the highest value. + +**Rationale**: An earlier design added a parallel `apiportal.io/version` annotation duplicating +`info.version` — the same fact, typed twice, with nothing keeping the two in sync if one is bumped +and not the other. Reading `info.version` directly has exactly one source of truth and costs +nothing extra: `spec.definition` is already fetched for every sibling by the Versions card's +existing `catalogApi.getEntities()` call, and Backstage's own placeholder processor has already +resolved any `$text` reference into a plain string by the time the catalog serves it. A +manually-maintained `apiportal.io/latest: "true"` flag was also considered and rejected +separately, for the same drift risk: two entities could both claim "latest," or none could. + +**Alternatives considered**: The original `apiportal.io/version` annotation approach — rejected for +duplicating spec data with no sync guarantee (see above). A manual `is-latest` boolean annotation — +rejected for the drift risk described above. Semantic-version libraries for full semver comparison +— rejected as overkill; major.minor.patch numeric comparison is sufficient for this lab's +two-version demo. ## R3: Representing lifecycle state (development/test/production/deprecated/retired) **Decision**: Reuse Backstage's native `spec.lifecycle` field on `API` entities — it is a free-form -string already rendered as a chip on the entity's built-in "About" card, with zero new code. Adopt -a documented convention of five values across this lab: `development`, `testing`, `production`, -`deprecated`, `retired` (the last two are additionally read by the new Versions card to decide -default visibility — see R5). +string already rendered as a chip on the entity's built-in "About" card, with zero new code. The +*value* comes from each spec's own `info.x-.lifecycle` field, extracted by Lab 4's +existing `autoApiRegistration.ts` mechanism (the same one already used for `owner`/`visibility`) +and copied onto `spec.lifecycle` when the entity is built. Adopt a documented convention of five +values across this lab: `development`, `testing`, `production`, `deprecated`, `retired` (the last +two are additionally read by the new Versions card to decide default visibility — see R5). **Rationale**: `spec.lifecycle` is not a closed enum in Backstage — any string is accepted and -displayed — so no schema change or new field is needed. This keeps the entire lifecycle mechanism a -one-line YAML edit per version, consistent with a GitOps mental model, and requires no new backend -code. +displayed — so no schema change or new field is needed. An earlier design hand-authored +`spec.lifecycle` directly in a catalog-info.yaml, silently overriding whatever `x-. +lifecycle` the spec itself declared — two sources of truth for the same fact, with the +hand-authored one always winning invisibly. Reading it from the spec's own extension field instead +keeps lifecycle a one-line **spec** edit (not a catalog-file edit) picked up through Lab 4's +existing poll/watch cycle — no git push required, since `autoApiRegistration.ts` reads local files +directly. **Alternatives considered**: A custom annotation (e.g. `apiportal.io/lifecycle-state`) instead of -the native field — rejected because it would duplicate a field Backstage already renders natively, -and per Lab 2's established precedent (Principle-driven requirement that displayed metadata must be -the same data used elsewhere, not a copy), reusing the real `spec.lifecycle` field is the correct -choice, not a parallel copy. +the native field — rejected because it would duplicate a field Backstage already renders natively. +A hand-authored `spec.lifecycle` override in a catalog-info.yaml (this lab's original design) — +rejected once lifecycle was recognized as already-extracted metadata Lab 4's mechanism handles; +overriding it in a separate file just reintroduces a second, silently-winning source of truth for +the same fact, contrary to Lab 2's established precedent that displayed metadata must be the same +data used elsewhere, not a copy. ## R4: Surfacing versions, latest, and retirement state on the API page @@ -68,7 +105,8 @@ following the exact `EntityCardBlueprint` pattern already used by Lab 2's `apiVi - Reads the current entity's `spec.system`. - Calls `catalogApi.getEntities()` (via `useApi(catalogApiRef)`, already used elsewhere in the Backstage frontend) filtered to `kind: API` entities with that same `spec.system`. -- Sorts by the `apiportal.io/version` annotation (R2), flags the highest non-retired one "Latest". +- Sorts by `info.version`, parsed out of each sibling's `spec.definition` (R2), flags the highest + non-retired one "Latest". - Renders each sibling's version, lifecycle chip (reusing `spec.lifecycle`, R3), and a link (`EntityRefLink`) to its own page. - Versions whose `spec.lifecycle` is `retired` are collapsed by default behind a "Show retired @@ -106,42 +144,50 @@ Principle VIII's requirement to flag anything that doesn't generalize — this o disproportionate new backend surface area for a teaching lab, and it would actually work against a legitimate real-world need (being able to find a retired API's historical record via search). -## R6: Where v1/v2 catalog data and the new v2 spec live; superseding Lab 2's single entity - -**Decision**: Lab 6 supersedes Lab 2's single `museum-api` catalog entity with two versioned -entities, `museum-api-v1` and `museum-api-v2`, plus one new `System`, all newly authored under -`labs/lab-06-api-lifecycle-management/catalog/`. `app-config.yaml`'s `catalog.locations` list has -Lab 2's single `museum-api.yaml` location entry replaced with three new entries (System, v1, v2). -`museum-api-v1` points its `spec.definition.$text` at the exact same, unmodified Lab 1 OpenAPI file -(`labs/lab-01-base-backstage/apis/museum/openapi.yaml`) — no existing committed spec file is edited. -`museum-api-v2` is a new spec file, `labs/lab-06-api-lifecycle-management/apis/museum-v2/openapi.yaml`, +## R6: Where v1/v2 spec data lives; superseding Lab 2's single entity + +**Decision**: Lab 6 supersedes Lab 2's single `museum-api` catalog entity with two versioned, +**auto-registered** entities, `museum-api-v1` and `museum-api-v2` (entity names are slugified from +each spec's `info.title`, "Museum API v1"/"Museum API v2" — distinguishable titles are required +since Lab 4's provider names entities from the title, not the filename). Both live under +`labs/lab-06-api-lifecycle-management/apis/`, discovered by a second `autoApiRegistration` source +(R1a) rather than any catalog-info.yaml. `app-config.yaml`'s `catalog.locations` list has Lab 2's +single `museum-api.yaml` location entry removed, with no replacement entries added (auto- +registration needs no `catalog.locations` entry at all). `apis/museum-v1/museum-v1-openapi.yaml` +is a **content-identical local copy** of Lab 1's OpenAPI file — a local copy, not a `$text` +reference, because Lab 4's file-glob provider only reads local files, and Lab 1's already- +committed spec file is never edited. `apis/museum-v2/museum-v2-openapi.yaml` is a new spec file containing deliberate, documented breaking changes against v1 (e.g. renaming the `eventId` path -parameter to `id`, and removing/renaming the QR-code ticket endpoint), with `info.version: 2.0.0`. +parameter to `id`, and renaming the QR-code ticket endpoint), with `info.version: 2.0.0`. **Rationale**: The lab brief explicitly asks for "multiple major versions of the same API," which requires the old single, unversioned entity name to be retired in favor of two clearly-versioned ones — a real breaking/superseding change to the environment, which the constitution's Development Workflow section explicitly permits ("Breaking changes to a lab's environment MUST be reflected in -all subsequent labs"). Reusing v1's OpenAPI file unchanged keeps the "difference between versions" -concrete and entirely attributable to the new v2 file, rather than muddying what changed. +all subsequent labs"). Keeping v1's OpenAPI content unchanged (aside from the required `x-examplecorp` +block and title) keeps the "difference between versions" concrete and entirely attributable to the +new v2 file, rather than muddying what changed. **Alternatives considered**: Leaving the original `museum-api` entity name in place as "v1" (no rename). Rejected: it would leave one version unversioned in name while its sibling is `-v2`, -undermining the "these are parallel, equally-versioned entities" lesson and complicating the -Versions card's version-parsing logic for no benefit. +undermining the "these are parallel, equally-versioned entities" lesson. Keeping `museum-api-v1` +as a hand-authored catalog-info.yaml pointing at Lab 1's spec via `$text` (this lab's original +design) — rejected once both versions were brought under Lab 4's auto-registration mechanism, since +it would leave exactly one hand-authored catalog file in an otherwise fully auto-registered lab. ## R7: Deprecation → retirement is a metadata edit, not a new action/button **Decision**: All lifecycle transitions (`development` → `testing` → `production`, and -`deprecated` → `retired`) are demonstrated as one-line edits to a version's committed -`spec.lifecycle` value, picked up through the catalog's existing location-polling mechanism — the -same GitOps-style flow already used for every other catalog metadata change in this repository. -No new "deprecate"/"retire" UI action or backend mutation endpoint is introduced. +`deprecated` → `retired`) are demonstrated as one-line edits to a version's spec file's own +`info.x-examplecorp.lifecycle` value (R3), picked up through Lab 4's existing file-glob poll/watch +mechanism rather than a catalog location refresh — since the spec files live on local disk, no +commit/push is required, unlike Labs 2–5's `type: url` catalog entries. No new "deprecate"/"retire" +UI action or backend mutation endpoint is introduced. **Rationale**: Consistent with this repository's existing pattern (every catalog metadata change -so far — ownership, visibility, lifecycle — is a YAML edit, not an in-app mutation), and keeps the -lab's new surface area limited to one read-only frontend card (R4) plus catalog config, with no new -backend module at all. +so far — ownership, visibility, lifecycle — is a declarative-file edit, not an in-app mutation), +and keeps the lab's new surface area limited to one read-only frontend card (R4) plus the +`autoApiRegistration.ts` extension (R1a), with no new backend module of its own. **Alternatives considered**: A button/action in the Versions card that calls a new backend endpoint to mutate lifecycle state directly. Rejected: real production lifecycle changes to committed API @@ -151,11 +197,12 @@ the lesson. ## R8: Scale check (Constitution Principle VIII) -**Decision**: The chosen mechanism (`System` entity + `spec.system` relation + `apiportal.io/version` -annotation + one read-only frontend card whose catalog query is filtered to the current entity's own -`spec.system`) has no scaling ceiling: adding another logical API's version family at real scale -(500+ APIs) is exactly one more `System` YAML file plus N more `API` YAML files — a config-only -change, no code or architecture change. The Versions card's `catalogApi.getEntities()` call is +**Decision**: The chosen mechanism (`System` entity + `spec.system` relation, both auto-derived +from each spec's own `x-examplecorp.apiBasename` + `info.version`, plus one read-only frontend card +whose catalog query is filtered to the current entity's own `spec.system`) has no scaling ceiling: +adding another logical API's version family at real scale (500+ APIs) is exactly N more spec files +declaring the same `apiBasename` — a config-only change (new spec files, not new catalog YAML or +code), no code or architecture change. The Versions card's `catalogApi.getEntities()` call is filtered server-side to one `spec.system` value, so its cost is proportional to the number of sibling versions of the one API being viewed (typically single digits), not to total catalog size. diff --git a/specs/006-api-lifecycle-management/tasks.md b/specs/006-api-lifecycle-management/tasks.md index ace3f84..5a12c00 100644 --- a/specs/006-api-lifecycle-management/tasks.md +++ b/specs/006-api-lifecycle-management/tasks.md @@ -20,124 +20,243 @@ description: "Task list for Lab 6 — API Lifecycle Management" ## Path Conventions -This is a tutorial-lab project (not a generic web/mobile app). Three path roots are used: +This is a tutorial-lab project (not a generic web/mobile app). Four path roots are used: -- **Backstage instance** (modified in place, from Lab 1): `labs/lab-01-base-backstage/backstage/` +- **Backstage instance** (modified in place, from Lab 1; gitignored, not committed): `labs/lab-01-base-backstage/backstage/` +- **Lab 4 shared backend module** (additively extended, not owned by this lab): `labs/lab-04-auto-registration/code/packages/backend/` - **Lab 6 teaching content** (new, committed alongside README): `labs/lab-06-api-lifecycle-management/` - **Speckit artifacts**: `specs/006-api-lifecycle-management/` (this directory — no code changes here) -**Note**: This lab supersedes Lab 2's single `museum-api` catalog entry with two versioned entities -(`museum-api-v1`, `museum-api-v2`) grouped under one new `System` entity — an explicitly-permitted -"breaking change to the environment" (research.md R6, plan.md Principle VI gate). +**Note**: This lab supersedes Lab 2's single `museum-api` catalog entry with two versioned, **auto-registered** +entities (`museum-api-v1`, `museum-api-v2`) — an explicitly-permitted "breaking change to the environment" +(research.md R6, plan.md Principle VI gate). Unlike the original design, **zero catalog YAML** is +authored anywhere in this lab: both API entities and the `System` that groups them are produced by +an additive extension to Lab 4's existing `autoApiRegistration.ts` provider (research.md R1a). --- ## Phase 1: Setup (Shared Infrastructure) -**Purpose**: Scaffold the directories every later task writes into. No new dependencies are needed (research.md — zero new frontend/root packages). +**Purpose**: Scaffold the directories every later task writes into. No new dependencies are needed +(`js-yaml` is already a `packages/app` dependency as of Lab 5's `apiMocking` module). -- [X] T001 Create the lab content directories `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/` and `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/` +- [X] T001 Create the lab content directories `labs/lab-06-api-lifecycle-management/apis/museum-v1/`, + `labs/lab-06-api-lifecycle-management/apis/museum-v2/`, and + `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/` -**Checkpoint**: Directories exist — ready for foundational work. +**Checkpoint**: Directories exist — foundational work can begin. --- ## Phase 2: Foundational (Blocking Prerequisites) -**Purpose**: Establish the catalog data (System + both versioned API entities, including the new v2 spec) and the module-registration point every user story extends. No user story is independently testable until this phase is done. +**Purpose**: Give Lab 4's `autoApiRegistration.ts` the one capability it doesn't already have +(`apiBasename` → `spec.system` + synthesized `System` entities), and add both Museum API spec +files it will discover. Every user story depends on this phase — none of them can be demonstrated +without both API versions existing and being groupable. **⚠️ CRITICAL**: No user story work can begin until this phase is complete. -- [X] T002 [P] Create the `System` entity `system:default/museum-api` in `labs/lab-06-api-lifecycle-management/catalog/system.yaml` (owner: `group:default/museum-team`, per data-model.md) -- [X] T003 [P] Create `museum-api-v1.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/`: `metadata.name: museum-api-v1`, `annotations['apiportal.io/version']: "1.0"`, `example.com/visibility: private` (unchanged from Lab 2), `spec.lifecycle: production`, `spec.system: museum-api`, `spec.definition.$text` pointing at Lab 1's existing, unmodified `labs/lab-01-base-backstage/apis/museum/openapi.yaml` raw URL (research.md R6, data-model.md) -- [X] T004 [P] Author `museum-v2/openapi.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/`: a deliberately-breaking variant of the Museum API — rename the `eventId` path parameter to `id` across the `/special-events/{eventId}` family of operations, and remove or rename the `/tickets/{ticketId}/qr` endpoint — with `info.version: 2.0.0` (research.md R6) -- [X] T005 Create `museum-api-v2.yaml` in `labs/lab-06-api-lifecycle-management/catalog/apis/`: `metadata.name: museum-api-v2`, `annotations['apiportal.io/version']: "2.0"`, `example.com/visibility: private`, `spec.lifecycle: development`, `spec.system: museum-api`, `spec.definition.$text` pointing at T004's new spec file's raw URL (depends on T004) -- [X] T006 Update `labs/lab-01-base-backstage/backstage/app-config.yaml`'s `catalog.locations`: remove Lab 2's single `museum-api.yaml` location entry, add three new `type: url` entries for `system.yaml`, `museum-api-v1.yaml`, and `museum-api-v2.yaml` (research.md R6, depends on T002, T003, T005) -- [X] T007 Scaffold the `apiVersions` frontend module as an `EntityCardBlueprint` (`filter: 'kind:API'`, `type: 'info'`) rendering a placeholder `ApiVersionsCard` that shows only the current entity's own name and `apiportal.io/version` annotation — no sibling lookup yet — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/{index.ts,ApiVersionsCard.tsx}` (research.md R4, same `EntityCardBlueprint` pattern as Lab 2's `apiVisibility`/Lab 3's `apiGrade`) -- [X] T008 Copy the scaffolded module to `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/` and register `apiVersionsModule` in `labs/lab-01-base-backstage/backstage/packages/app/src/App.tsx`'s `features` array (depends on T007) - -**Checkpoint**: Both API versions and their System are registered and related; the card scaffold renders without error — ready for US1. +- [X] T002 [P] Create migration `002_add_system_slug.ts` in + `labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistrationMigrations/`: + a nullable `system_slug` column on the scan-state cache table (research.md R1a) +- [X] T003 Wire the new migration into `ScanStateCache.create()`'s `migrationSource` map (keyed + alongside `001_scan_state_cache`) in + `labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts` + (depends on T002) +- [X] T004 Add `system_slug: string | null` to the `CacheRow` interface and `systemSlug?: string` + to `MappingResult` in `autoApiRegistration.ts` (depends on T003) +- [X] T005 In `mapCandidate()` (`autoApiRegistration.ts`), extract `x-.apiBasename` via + the existing `xField()` helper, slugify it with the existing `slugify()` helper, and pass it + through to `buildEntity()` as `spec.system` on successful (non-error) mappings only + (depends on T004) +- [X] T006 Add `buildSystemEntity()` to `autoApiRegistration.ts` and, in `runCycle()`, recompute the + full set of currently-implied `System` slugs each cycle from the scan-state cache (not just + files that changed this cycle) and include one synthesized `System` `DeferredEntity` per + distinct slug in both the first-run `full` mutation and any triggered `delta` mutation + (research.md R1a, depends on T005) +- [X] T007 [P] Fix `normalizeConfig()`'s `rootPath` resolution in `autoApiRegistration.ts` so a + user-supplied relative `rootPath` resolves against `packages/backend`'s own directory (the + same anchor `defaultRootPath()` already uses), not the process's `cwd` — required for a + second, independently-rooted source to behave predictably regardless of where `yarn start` + is invoked from +- [X] T008 [P] Create `labs/lab-06-api-lifecycle-management/apis/museum-v1/museum-v1-openapi.yaml`: + a content-identical local copy of Lab 1's `museum/openapi.yaml` (Lab 1's own committed file + is never edited), titled `"Museum API v1"` (distinguishable from v2's title, since + `autoApiRegistration.ts` names entities from a slug of `info.title`), with an + `info.x-examplecorp` block (`owner: group:default/museum-team`, `visibility: private`, + `lifecycle: production`, `apiBasename: museum-api`) (research.md R6, data-model.md) +- [X] T009 [P] Create `labs/lab-06-api-lifecycle-management/apis/museum-v2/museum-v2-openapi.yaml`: + a deliberately-breaking variant of the Museum API — rename the `eventId` path parameter to + `id` across the `/special-events/{eventId}` family of operations, and rename + `/tickets/{ticketId}/qr` to `/tickets/{ticketId}/qr-code` — titled `"Museum API v2"`, + `info.version: 2.0.0`, with an `info.x-examplecorp` block (`lifecycle: development`, + `apiBasename: museum-api`, same owner/visibility as v1) (research.md R6) +- [X] T010 Update `labs/lab-01-base-backstage/backstage/app-config.yaml`: convert + `autoApiRegistration` from Lab 4's single-source shorthand to the explicit `sources:` array + form, add a second source (`id: lab6-museum-api`, `rootPath` pointing at this lab's `apis/` + directory, `defaultOwner: group:default/museum-team`, `xNamespace: examplecorp` — shared with + the `default` source, since the namespace identifies the organization, not the tool reading + it); remove Lab 2's single `museum-api.yaml` `catalog.locations` entry with no replacement + entries added (depends on T007, T008, T009) +- [X] T011 [P] Scaffold the `apiVersions` frontend module as an `EntityCardBlueprint` + (`filter: 'kind:API'`, `type: 'info'`) rendering a placeholder `ApiVersionsCard` in + `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/{index.ts,ApiVersionsCard.tsx}` + (research.md R4, same `EntityCardBlueprint` pattern as Lab 2's `apiVisibility`/Lab 3's + `apiGrade`) + +**Checkpoint**: Restarting Backstage now auto-registers `museum-api-v1` and `museum-api-v2` with no +catalog-info.yaml, and synthesizes a `museum-api` `System` entity with no `system.yaml` — the +foundation every user story below builds on. --- ## Phase 3: User Story 1 - Register and browse multiple major versions of the same API (Priority: P1) 🎯 MVP -**Goal**: A learner sees `museum-api-v1` and `museum-api-v2` coexisting in the catalog, visibly presented as versions of the same logical API, with a clear way to navigate between them. +**Goal**: Both Museum API versions exist in the catalog at the same time, visibly related to one +another via the `System` grouping, with a way to navigate between them. -**Independent Test**: Register both versions, confirm both appear in the catalog at the same time, and confirm each version's page shows a working link to the other. +**Independent Test**: Confirm `museum-api-v1` and `museum-api-v2` both appear as separate catalog +entities at the same time, grouped under the same `museum-api` System, with neither hiding the +other. ### Implementation for User Story 1 -- [X] T009 [US1] Implement sibling-lookup logic in `ApiVersionsCard.tsx`: read the current entity's `spec.system`, call `catalogApi.getEntities()` (via `useApi(catalogApiRef)`) filtered to `kind: API` entities sharing that `spec.system`, and render each sibling's name/version as a row linking to its own page via `EntityRefLink` — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (data-model.md, research.md R1/R4, depends on T007) -- [X] T010 [US1] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T009, T008) -- [X] T011 [US1] Manually verify (quickstart.md steps 4–5): the catalog shows `museum-api-v1`, `museum-api-v2`, and the `museum-api` System as separate entities existing at the same time; the System page lists both as "Has part" APIs; the Versions card on either version's page lists both, and following a link reaches the other (SC-001, depends on T006, T010) +- [X] T012 [US1] Implement `useApiSiblings()` in + `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts`: + read the current entity's `spec.system` and call `catalogApi.getEntities()` filtered to + `kind: API` entities sharing it +- [X] T013 [US1] Implement sibling rendering in `ApiVersionsCard.tsx`: list every sibling's name + with an `EntityRefLink` to its own page (current entity rendered as plain text, not a link) + (depends on T012) +- [X] T014 [US1] Verify (quickstart.md step 5): restart Backstage, confirm `museum-api-v1` and + `museum-api-v2` both appear with no hand-authored catalog file for either, and that the + `museum-api` System's page lists both as "Has part" APIs -**Checkpoint**: User Story 1 fully functional and independently testable — this is the MVP. +**Checkpoint**: User Story 1 is fully functional and independently testable. --- ## Phase 4: User Story 2 - Discover the latest version by default, while older versions remain reachable (Priority: P1) -**Goal**: The Versions card flags the latest version prominently while keeping older versions one click away. +**Goal**: The Versions card computes and flags the latest version from each spec's own +`info.version` — no manually-maintained flag, no duplicated annotation. -**Independent Test**: View the card on either version's page and confirm v2 is flagged Latest; follow the card's link from v2 to v1 and confirm v1 is fully accessible; confirm searching for v1 by name still resolves it directly. +**Independent Test**: Confirm v2 (`info.version: 2.0.0`) is flagged "Latest" against v1 +(`info.version: 1.0.0`), and that v1 remains fully reachable via the same card. ### Implementation for User Story 2 -- [X] T012 [US2] Add "Latest" computation to `ApiVersionsCard.tsx`: parse each sibling's `apiportal.io/version` annotation as a `[major, minor]` pair, compare numerically among siblings whose `spec.lifecycle !== 'retired'` (falling back to comparing all siblings if every one is retired, per spec.md's edge case), and render a "Latest" badge on the highest — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R2, depends on T009) -- [X] T013 [US2] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T012, T010) -- [X] T014 [US2] Manually verify (quickstart.md step 5): v2 is flagged Latest in the card on both v1 and v2's pages; v1 is reachable from v2's card within one click; navigating directly to `museum-api-v1` by name still resolves and opens its page (SC-002, depends on T013) +- [X] T015 [US2] Implement `getVersionString()`/`getVersion()`/`compareVersions()`/`findLatest()` + in `versionUtils.ts`: parse `info.version` out of `spec.definition` (already a + placeholder-resolved plain string) via `js-yaml`, never a catalog annotation (research.md R2) +- [X] T016 [US2] Render each sibling's `v{version}` label and a "Latest" `Chip` in + `ApiVersionsCard.tsx`'s `VersionRow`, computed via `findLatest()` (depends on T015) +- [X] T017 [US2] Verify (quickstart.md step 6): confirm `museum-api-v2` is flagged Latest, computed + from `info.version` (`1.0.0` vs. `2.0.0`), and that v1 is still one click away -**Checkpoint**: User Stories 1 AND 2 both work independently — versions coexist, are related, and the latest is prominent. +**Checkpoint**: User Stories 1 and 2 both work independently. --- ## Phase 5: User Story 3 - Track lifecycle state independently per version (Priority: P2) -**Goal**: Each version displays and can independently progress through its own lifecycle state, both on its own page and in the Versions card. +**Goal**: Each version's lifecycle state comes from its own spec's `x-examplecorp.lifecycle` field +(the same extraction mechanism Lab 4 already built for owner/visibility) and is editable without +touching any catalog YAML or the sibling version's spec. -**Independent Test**: Confirm v1 shows `production` and v2 shows `development` independently; advance v2 through `testing` then `production` and confirm only v2's displayed state changes. +**Independent Test**: Set v1's spec to `lifecycle: production` and v2's to `lifecycle: development`, +confirm both pages display their own independent state, then advance v2 through +`testing` → `production` and confirm v1 is unaffected. ### Implementation for User Story 3 -- [X] T015 [US3] Add a lifecycle chip to each sibling row in `ApiVersionsCard.tsx`, reusing each entity's existing `spec.lifecycle` value (no new field) — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R3, depends on T012) -- [X] T016 [US3] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T015, T013) -- [X] T017 [US3] Manually verify (quickstart.md step 5): v1's own About card and its row in the Versions card both show `production`; v2's both show `development`; the two differ (SC-003, depends on T016) -- [X] T018 [US3] Edit `museum-api-v2.yaml`'s `spec.lifecycle` from `development` to `testing`, then to `production` (two sequential edits); after each, refresh and confirm the displayed chip (own page and Versions card) updates to match, and that v1's entry is unaffected (SC-004, quickstart.md step 6, depends on T017) - -**Checkpoint**: All three user stories independently functional — versions coexist, latest is prominent, and lifecycle is tracked per version. +- [X] T018 [US3] Implement `getLifecycle()`/`isRetired()` in `versionUtils.ts`, reading + `entity.spec.lifecycle` (populated by `autoApiRegistration.ts` from each spec's + `x-examplecorp.lifecycle` — no code change needed here, Lab 4 already extracts it) +- [X] T019 [US3] Render each sibling's lifecycle `Chip` in `ApiVersionsCard.tsx`'s `VersionRow` + (depends on T018) +- [X] T020 [US3] Verify (quickstart.md step 6): confirm v1 (`production`) and v2 (`development`) + show independent lifecycle chips on their own "About" cards and in the Versions card +- [X] T021 [US3] Verify (quickstart.md step 7): edit `museum-v2-openapi.yaml`'s + `info.x-examplecorp.lifecycle` from `development` → `testing` → `production` (two sequential + edits); after each, wait for the next `autoApiRegistration` poll cycle and confirm the + displayed chip (own page and Versions card) updates with no commit/push required, and that + v1's entry is unaffected + +**Checkpoint**: User Stories 1–3 all work independently. --- ## Phase 6: User Story 4 - Deprecate and then retire an API version (Priority: P2) -**Goal**: An older version can be marked deprecated (visibly flagged everywhere) and later retired (collapsed from the default Versions view, but never deleted). +**Goal**: A version can be marked `deprecated` and later `retired` as two distinct steps, with a +loud main-content warning on deprecated/retired pages, retired versions collapsed by default (but +never deleted) in the Versions card. -**Independent Test**: Mark v1 deprecated and confirm the label appears everywhere it's shown; mark v1 retired and confirm it collapses behind a "Show retired versions" toggle while remaining reachable via direct link. +**Independent Test**: Mark v1 `deprecated`, confirm the label appears everywhere v1 appears; mark +it `retired`, confirm it collapses behind "Show retired versions" while remaining directly +reachable and never deleted. ### Implementation for User Story 4 -- [X] T019 [US4] Add retired-version collapsing to `ApiVersionsCard.tsx`: siblings with `spec.lifecycle === 'retired'` are grouped behind a closed-by-default "Show retired versions (N)" toggle (local component state), each still rendering its Deprecated/Retired lifecycle chip and working link when expanded — in `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (research.md R4/R5, depends on T015) -- [X] T020 [US4] Copy the updated card into `labs/lab-01-base-backstage/backstage/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` (depends on T019, T016) -- [X] T021 [US4] Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `deprecated`; confirm the Deprecated label appears on v1's own page and in the Versions card on both v1 and v2's pages (quickstart.md step 7, depends on T020) -- [X] T022 [US4] Edit `museum-api-v1.yaml`'s `spec.lifecycle` to `retired`; confirm v1 now only appears in the Versions card behind the "Show retired versions" toggle (collapsed by default) on both pages, while a direct link to `museum-api-v1` still opens it, clearly labeled Retired; confirm the entity itself is still present in the catalog — never deleted (SC-005, SC-006, quickstart.md steps 8–9, depends on T021) - -**Checkpoint**: All four user stories independently functional — this is the full lab. +- [X] T022 [US4] Create `ApiLifecycleBanner.tsx` in + `labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/`: a + `content`-type `EntityCardBlueprint` card that renders nothing unless `spec.lifecycle` is + `deprecated`/`retired`, in which case it shows a `WarningPanel` naming the latest version to + use instead; register it alongside `apiVersionsCard` in `index.ts` +- [X] T023 [US4] Add "Show retired versions (N)" collapse/toggle logic to `ApiVersionsCard.tsx`: + filter siblings whose `isRetired()` is true out of the default list, revealed via local + component state (no persistence needed) +- [X] T024 [US4] Document the `app-config.yaml` `app.extensions` entry + (`entity-card:catalog/api-lifecycle-banner`) needed to pin the banner above the + auto-discovered `api-docs` Definition card, in the README's Step 3 +- [X] T025 [US4] Verify (quickstart.md step 8): edit `museum-v1-openapi.yaml`'s + `info.x-examplecorp.lifecycle` to `deprecated`; confirm the Deprecated label and banner + appear on v1's own page and in the Versions card on both v1 and v2's pages +- [X] T026 [US4] Verify (quickstart.md step 9–10): edit `museum-v1-openapi.yaml`'s + `info.x-examplecorp.lifecycle` to `retired`; confirm v1 collapses behind "Show retired + versions (1)" on both pages, remains fully viewable via a direct link (clearly labeled + Retired), and that the entity itself is never deleted from the catalog (SC-005, SC-006) + +**Checkpoint**: All four user stories are independently functional. --- ## Phase 7: Polish & Cross-Cutting Concerns -**Purpose**: Learner-facing documentation and final repo-wide consistency checks that span multiple stories. - -- [X] T023 Write `labs/lab-06-api-lifecycle-management/README.md` Overview/Prerequisites/Steps sections (Constitution Principle II) covering: add the System/v1/v2 catalog entries and v2 spec, update `app-config.yaml` locations, scaffold/register the `apiVersions` module, start Backstage, and walk through latest-prominence, lifecycle-progression, and deprecate-then-retire verification — following quickstart.md steps 1–9, and linking to the committed `labs/lab-06-api-lifecycle-management/catalog/apis/museum-v2/openapi.yaml` and `code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx` rather than inlining them (both exceed the ~40–50 line threshold) (depends on T011, T014, T018, T022) -- [X] T024 Add the README's "Why a System entity, not a custom annotation" and "Why latest is computed, not hand-flagged" sections (research.md R1, R2) -- [X] T025 [P] Add the README's "Why Backstage's native search/catalog table is left unmodified" section (research.md R5) — framed as a documented design boundary, not a gap, per Constitution Principle VIII -- [X] T026 [P] Add the README's "Adaptable conventions vs. fixed mechanics" section (FR-013): the `apiportal.io/version` format, the five-value lifecycle convention, and the "one System per logical API" pattern are adaptable; the `System`/`spec.system` relation and the `EntityCardBlueprint` mechanism itself are fixed -- [X] T027 Add the README's Verification and Troubleshooting sections (Lab Structure Standards) covering the manual checks already performed in T011/T014/T017/T018/T021/T022, plus common failure modes (forgetting to remove Lab 2's old `museum-api.yaml` catalog location, a `spec.system` typo silently producing an empty Versions card) -- [X] T028 Update root `README.md` (Lab Series table entry, Getting Started tree, Repository Structure tree) to reference `labs/lab-06-api-lifecycle-management/` and `specs/006-api-lifecycle-management/`, per the Constitution's Lab Structure Standards — required for this lab to be considered complete -- [X] T029 [P] Re-verify the Constitution Check table in plan.md against the final implementation (all 9 principles, with particular attention to Principle VI's "breaking change to the environment" justification and Principle VIII's scale claims) and correct plan.md if any gate's justification no longer matches what was built -- [X] T030 Run the full quickstart.md validation sequence (steps 1–9) end-to-end against a clean `yarn start` of `labs/lab-01-base-backstage/backstage/` to confirm the lab works from a cold start, not just incrementally during development +**Purpose**: Documentation and repository-wide consistency, not gated behind any single story. + +- [X] T027 [P] Write `labs/lab-06-api-lifecycle-management/README.md` (Overview/Prerequisites/Steps + 1–9, Constitution Principle II) covering: adding the two OpenAPI spec files, registering the + second `autoApiRegistration` source, scaffolding/registering the `apiVersions` module, + starting Backstage, and walking through latest-prominence, lifecycle-progression, and + deprecate-then-retire verification — linking to the committed spec files and + `code/packages/app/src/modules/apiVersions/` rather than inlining them (each exceeds the + ~40–50 line threshold) +- [X] T028 [P] Add the README's "Why" sections explaining each design decision that isn't + self-evident from the code: System entity vs. bespoke annotation (research.md R1), + auto-synthesizing the System instead of hand-authoring it (R1a), reading `info.version` + instead of a duplicated annotation (R2), reusing the spec's own `x-examplecorp.lifecycle` + field instead of a catalog override (R3), leaving native search unmodified (R5), and + superseding (not editing) Lab 2's entry (R6) — including why `x-examplecorp` (not a + per-lab/per-tool namespace like `x-apiportal`) is the correct, reused vendor namespace +- [X] T029 [P] Add the README's "Adaptable Conventions vs. Fixed Mechanics" section (FR-013): the + `x-examplecorp` field set (`owner`/`visibility`/`lifecycle`/`apiBasename`) and the five-value + lifecycle convention are adaptable; the `System`/`spec.system` relation, its auto-synthesis + from `apiBasename`, the `EntityCardBlueprint` mechanism, and reading `info.version` directly + are fixed +- [X] T030 [P] Add the README's Troubleshooting section: wrong/mismatched `apiBasename` values + producing an empty sibling list, a misconfigured second-source `rootPath`, a missing + `museum-team` owner group, and the (now removed) need for any commit/push step for lifecycle + edits +- [X] T031 [P] Update `labs/lab-04-auto-registration/README.md`'s "Adaptable Conventions" `rootPath` + bullet to document the `packages/backend`-relative resolution fix (T007), pointing at Lab 6's + second source as a worked example +- [X] T032 Confirm `labs/lab-06-api-lifecycle-management/README.md` is linked from the root + `README.md`'s Lab Series table, Getting Started tree, and Repository Structure tree, per the + Constitution's Lab Structure Standards (already satisfied — no edit required) +- [X] T033 Run quickstart.md's full 10-step validation end-to-end against a real Backstage instance --- @@ -145,49 +264,29 @@ This is a tutorial-lab project (not a generic web/mobile app). Three path roots ### Phase Dependencies -- **Setup (Phase 1)**: No dependencies — start immediately -- **Foundational (Phase 2)**: Depends on Setup completion — BLOCKS all user stories -- **User Story 1 (Phase 3)**: Depends on Foundational completion — no dependency on US2/US3/US4 -- **User Story 2 (Phase 4)**: Depends on Foundational completion; extends the same file US1 built (T009), so in practice follows US1 sequentially even though it introduces no new *architectural* dependency -- **User Story 3 (Phase 5)**: Depends on Foundational completion; extends the file US1/US2 built (T012) — follows US2 sequentially for the same reason -- **User Story 4 (Phase 6)**: Depends on Foundational completion; extends the file US1/US2/US3 built (T015) — follows US3 sequentially +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on Setup completion — BLOCKS all user stories (both API + versions and the System they're grouped under must exist before any story can be demonstrated) +- **User Stories (Phase 3–6)**: All depend on Foundational phase completion + - US1 and US2 are both P1 and independent of each other, but US2's "Latest" flag only makes + sense once US1's sibling list renders — implement in order for a sane demo, even though + neither's *code* depends on the other's + - US3 and US4 are both P2; US4 (deprecate/retire) builds narratively on US3 (lifecycle display) + but does not share any implementation task - **Polish (Phase 7)**: Depends on all four user stories being complete -### User Story Dependencies - -- **User Story 1 (P1)**: Can start after Foundational — no dependency on other stories -- **User Story 2 (P1)**: Can start after Foundational, but shares `ApiVersionsCard.tsx` with US1 (same file, sequential edits) — implement after US1 for a working MVP checkpoint first -- **User Story 3 (P2)**: Can start after Foundational, but shares the same file with US1/US2 — implement after US2 -- **User Story 4 (P2)**: Can start after Foundational, but shares the same file with US1/US2/US3 — implement after US3 - ### Within Each User Story -- Card logic change before the copy-into-Backstage-instance task -- Catalog YAML edit(s) (where applicable) before manual verification -- Manual verification task last in each phase (checkpoint gate) +- `versionUtils.ts` helpers before `ApiVersionsCard.tsx`/`ApiLifecycleBanner.tsx` rendering that + calls them +- Implementation before its quickstart verification task ### Parallel Opportunities -- T002, T003, T004 (Foundational: System entity, v1 descriptor, v2 spec file) can run in parallel — different files, no dependency between them -- T024/T025/T026 (Polish README sections) in parallel with each other -- T029 (Polish) in parallel with T025/T026 - ---- - -## Parallel Example: Foundational Catalog Data - -```bash -Task: "Create the System entity in labs/lab-06-api-lifecycle-management/catalog/system.yaml" -Task: "Create museum-api-v1.yaml in labs/lab-06-api-lifecycle-management/catalog/apis/" -Task: "Author museum-v2/openapi.yaml with deliberate breaking changes" -``` - -## Parallel Example: Polish README Sections - -```bash -Task: "Add 'Why native search is left unmodified' section" -Task: "Add 'Adaptable conventions vs. fixed mechanics' section" -``` +- T002 and T007–T009 can run in parallel (different files, no shared dependency) +- T027–T031 (all README/doc tasks) can run in parallel once Phases 3–6 are complete +- Foundational tasks T002–T006 are a strict sequence (each edits the same + `autoApiRegistration.ts` file); T007–T009 can proceed in parallel alongside them --- @@ -196,29 +295,30 @@ Task: "Add 'Adaptable conventions vs. fixed mechanics' section" ### MVP First (User Story 1 Only) 1. Complete Phase 1: Setup -2. Complete Phase 2: Foundational (CRITICAL — blocks all stories) +2. Complete Phase 2: Foundational (CRITICAL — blocks all stories; this is where the + auto-registration extension lives) 3. Complete Phase 3: User Story 1 -4. **STOP and VALIDATE**: T011 confirms both versions coexist, are visibly related, and are mutually navigable -5. Demo: two major versions of the Museum API, registered and linked, with no version overwriting the other +4. **STOP and VALIDATE**: Confirm both versions register and group correctly with zero catalog YAML +5. Demo if ready ### Incremental Delivery -1. Setup + Foundational → catalog data (System, v1, v2) and card scaffold ready -2. Add User Story 1 → validate independently (T011) → MVP demo -3. Add User Story 2 → validate independently (T014) → latest-prominence confirmed -4. Add User Story 3 → validate independently (T017/T018) → per-version lifecycle confirmed -5. Add User Story 4 → validate independently (T021/T022) → deprecate-then-retire confirmed -6. Polish → README (including the design-boundary worked example), root README update, cold-start validation, constitution re-check - -### Notes - -- US1 through US4 all extend one shared file (`ApiVersionsCard.tsx`) because each story adds a - layer to the same component — a deliberate incremental design (mirroring Lab 5's US1/US3 sharing - `apiMocking/index.tsx`), not an accidental cross-story coupling. Each story still has an - independently verifiable acceptance test even though the code lands in the same file. -- No test tasks are included (Tests: Not requested — see header). Verification is manual, per - quickstart.md, folded into each story's final task(s). -- Lab 2's single `museum-api` catalog entry is superseded (not edited in place) by - `museum-api-v1`/`museum-api-v2` — an explicitly-permitted "breaking change to the environment" - (research.md R6, plan.md Principle VI gate) — so no task edits Lab 2's own committed catalog file; - T006 only changes `app-config.yaml`'s location list. +1. Setup + Foundational → both API versions auto-registered, System synthesized +2. Add User Story 1 → sibling list renders → validate → demo (MVP!) +3. Add User Story 2 → Latest flag from `info.version` → validate → demo +4. Add User Story 3 → per-version lifecycle chips, editable via spec file → validate → demo +5. Add User Story 4 → deprecate/retire banner + collapse → validate → demo +6. Polish → README, cross-lab consistency, full quickstart run + +--- + +## Notes + +- [P] tasks = different files, no dependencies +- [Story] label maps task to specific user story for traceability +- This lab's Foundational phase is unusually load-bearing compared to a typical feature: because + the whole point is "zero hand-authored catalog YAML," the backend extraction/synthesis mechanism + (T002–T009) has to exist before *any* story-level UI work has real data to render against +- Verify each story's quickstart step before moving to the next priority +- Avoid: reintroducing a hand-authored `catalog-info.yaml`/`system.yaml` for anything this lab + registers — that would defeat the lab's core lesson From 86e622a267c77e00bf20f8bff8768d0b4840a8c1 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Sun, 5 Jul 2026 22:38:23 +1000 Subject: [PATCH 09/11] Auto-registered lifecycle change support --- .../src/extensions/autoApiRegistration.ts | 12 ++++- .../lab-06-api-lifecycle-management/README.md | 26 ++++++++-- .../src/modules/apiVersions/versionUtils.ts | 2 +- .../checklists/issues.md | 47 ++++++++++++++++++- .../006-api-lifecycle-management/research.md | 9 ++++ specs/006-api-lifecycle-management/tasks.md | 17 +++++++ 6 files changed, 105 insertions(+), 8 deletions(-) diff --git a/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts b/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts index 187a8dd..01b3fb3 100644 --- a/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts +++ b/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts @@ -295,12 +295,18 @@ function buildEntity(opts: { // synthesized purely from spec files, never hand-authored (see runCycle()'s per-cycle // resynchronization). function buildSystemEntity(providerName: string, slug: string, owner: string): Entity { + // Needs the same location annotations the API entities get: without one, the catalog's + // orphan-cleanup task treats a location-less entity as unowned and deletes it on its own + // schedule, regardless of the EntityProvider re-asserting it every cycle. + const location = `synthetic:${providerName}`; return { apiVersion: 'backstage.io/v1alpha1', kind: 'System', metadata: { name: slug, annotations: { + [ANNOTATION_LOCATION]: location, + [ANNOTATION_ORIGIN_LOCATION]: location, [MANAGED_BY_ANNOTATION]: providerName, }, }, @@ -441,7 +447,11 @@ class ScanStateCache { // `knex_migrations` table here would validate our one-migration `migrationSource` against the // real catalog plugin's own (much longer) applied-migrations history and fail with // "migration directory is corrupt". - const migrations: Record = { + interface MigrationModule { + up(knex: Knex): Promise; + down(knex: Knex): Promise; + } + const migrations: Record = { '001_scan_state_cache': scanStateCacheMigration, '002_add_system_slug': systemSlugMigration, }; diff --git a/labs/lab-06-api-lifecycle-management/README.md b/labs/lab-06-api-lifecycle-management/README.md index 399b764..9ab1296 100644 --- a/labs/lab-06-api-lifecycle-management/README.md +++ b/labs/lab-06-api-lifecycle-management/README.md @@ -94,10 +94,22 @@ info: `apiBasename` is new in this lab — see "Why apiBasename Drives System Assignment" below for why it exists and how the provider turns it into a `System` entity with no catalog YAML of its own. -## Step 2 — Register a Second `autoApiRegistration` Source - -`app-config.yaml`'s `autoApiRegistration` config is already in place, converted from Lab 4's -single-source shorthand to the explicit multi-source form (see +## Step 2 — Update the Shared Auto-Registration Module, Then Register a Second Source + +This lab's `apiBasename` → `System` behavior is an additive change to Lab 4's own +`autoApiRegistration.ts`, not new code of its own. Copy the updated +[`labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts`](../lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts) +over your Lab 4 copy at +`packages/backend/src/extensions/autoApiRegistration.ts`, and add the new migration file +[`002_add_system_slug.ts`](../lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistrationMigrations/002_add_system_slug.ts) +alongside Lab 4's existing `001_scan_state_cache.ts` in +`packages/backend/src/extensions/autoApiRegistrationMigrations/` — copy both in as-is, same as +Lab 4's own Step 2. Skipping this step is the single most common way to stall on Step 4 below: the +frontend module renders, but every API's "API Versions" card reports "not part of a System," +because `spec.system` and the synthesized `System` entity only exist once this file is updated. + +Then convert `app-config.yaml`'s `autoApiRegistration` config from Lab 4's single-source shorthand +to the explicit multi-source form (see [`labs/lab-04-auto-registration/README.md`'s "Scaling to Multiple Source Repositories"](../lab-04-auto-registration/README.md#scaling-to-multiple-source-repositories)): ```yaml @@ -361,6 +373,12 @@ Fixed — this is the mechanism itself, not a convention: - **"Owner ... does not resolve to a known User or Group entity"**: confirm `group:default/ museum-team` is registered (Lab 2's `teams.yaml`) — the same check Lab 4 already performs for every auto-registered API applies here too. +- **The `museum-api` System appears right after a restart, then disappears a cycle or two + later**: you're running an older copy of `autoApiRegistration.ts` whose `buildSystemEntity()` + doesn't set location annotations on the synthesized `System` — Backstage's own catalog + processing treats a location-less entity as an orphan and removes it. Re-copy the file per + Step 2 above; the current version sets a synthetic `backstage.io/managed-by-location` on every + `System` it builds specifically to avoid this. --- diff --git a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts index 1a7314f..f904dc4 100644 --- a/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { Entity } from '@backstage/catalog-model'; -import yaml from 'js-yaml'; +import * as yaml from 'js-yaml'; export const RETIRED = 'retired'; export const DEPRECATED = 'deprecated'; diff --git a/specs/006-api-lifecycle-management/checklists/issues.md b/specs/006-api-lifecycle-management/checklists/issues.md index c496a2f..0e51d95 100644 --- a/specs/006-api-lifecycle-management/checklists/issues.md +++ b/specs/006-api-lifecycle-management/checklists/issues.md @@ -1,6 +1,6 @@ ## Run 1 - 2026/07/05 -- [ ] Step 4 verification failed at the following steps: +- [x] Step 4 verification failed at the following steps: ```markdown - A `museum-api` `System` entity exists, listing both as "Has part" APIs, even though no @@ -9,4 +9,47 @@ - Opening either version's page shows the new "API Versions" card, listing both versions with working links between them, and `museum-api-v2` flagged **Latest** (computed from each spec's `info.version`, `1.0.0` vs. `2.0.0`). -``` \ No newline at end of file +``` + + **Resolved.** Two root causes, both fixed: + 1. `buildSystemEntity()` in `autoApiRegistration.ts` didn't set + `backstage.io/managed-by-location` / `backstage.io/managed-by-origin-location` on the + synthesized `System` entity, so Backstage's own catalog processing treated it as a + location-less orphan and removed it a cycle or two after every restart — it would appear + right after startup, then vanish. Fixed with a synthetic `synthetic:` location + value (research.md R1a "Gotcha", tasks.md T006a). + 2. The README's Step 2 never instructed copying the updated `autoApiRegistration.ts` (or the + new `002_add_system_slug.ts` migration) into the backend, and wrongly described + `app-config.yaml`'s multi-source config as "already in place" — a learner following the + README as written would never get `spec.system`/the `System` entity at all. Fixed by making + Step 2 explicitly instruct both copies before the config change (tasks.md T032a). + 3. (Incidental, caught while fixing the above) a TypeScript type error in `ScanStateCache.create()`'s + `migrations` record — typed as `Record`, which + doesn't structurally match `002_add_system_slug.ts` (no `TABLE_NAME` export) — fixed by + typing the record as a minimal `{ up, down }` migration-module interface instead. + + Verified end-to-end against a live `yarn start` instance: `museum-api-v1`/`museum-api-v2` both + register with no catalog-info.yaml, `museum-api` `System` persists across multiple poll/orphan- + cleanup cycles with `hasPart` relations to both versions, and each version's own `info.version` + (`1.0.0` vs `2.0.0`) is read correctly for the Latest flag. + +## Run 2 - 2026/07/05 + +- [x] This message is appearing when I try to access backstage now: + +``` +Compiled with problems: +× +WARNING in ./src/modules/apiVersions/versionUtils.ts 17:21-29 + ⚠ ESModulesLinkingWarning: export 'default' (imported as 'yaml') was not found in 'js-yaml' (possible exports: CHOMPING_CLIP, CHOMPING_KEEP, CHOMPING_STRIP, COLLECTION_STYLE_BLOCK, COLLECTION_STYLE_FLOW, CORE_SCHEMA, EVENT_ALIAS, EVENT_DOCUMENT, EVENT_MAPPING, EVENT_POP, EVENT_SCALAR, EVENT_SEQUENCE, FAILSAFE_SCHEMA, JSON_SCHEMA, MERGE_KEY, NOT_RESOLVED, SCALAR_STYLE_DOUBLE_QUOTED, SCALAR_STYLE_FOLDED_BLOCK, SCALAR_STYLE_LITERAL_BLOCK, SCALAR_STYLE_PLAIN, SCALAR_STYLE_SINGLE_QUOTED, Schema, Style, VISIT_BREAK, VISIT_SKIP, YAML11_SCHEMA, YAMLException, binaryTag, boolCoreTag, boolJsonTag, boolYaml11Tag, constructFromEvents, defineMappingTag, defineScalarTag, defineSequenceTag, dump, eventsToAst, floatCoreTag, floatJsonTag, floatYaml11Tag, getScalarValue, intCoreTag, intJsonTag, intYaml11Tag, jsToAst, legacyMapTag, load, loadAll, mapTag, mergeTag, nullCoreTag, nullJsonTag, nullYaml11Tag, omapTag, pairsTag, parseEvents, present, realMapTag, seqTag, setTag, strTag, timestampTag, visit) +``` + + **Resolved.** `versionUtils.ts` used `import yaml from 'js-yaml'` (a default import), but + `js-yaml@^5` (already the pinned version, per `packages/app/package.json`) is ESM-only with no + default export — only named exports (`load`, `dump`, etc.). The `apiMocking` module (Lab 5) + already uses the correct pattern for this same dependency: + `import * as yaml from 'js-yaml'`. Applied that fix to `versionUtils.ts` (tasks.md T015a); + confirmed the frontend now reports "Rspack compiled successfully" with no warnings, and + re-verified the full catalog state (both Museum API versions, the `museum-api` System with + `hasPart` relations, independent lifecycles, `info.version`-based Latest) is still correct + after the fix. diff --git a/specs/006-api-lifecycle-management/research.md b/specs/006-api-lifecycle-management/research.md index 5073704..5b03415 100644 --- a/specs/006-api-lifecycle-management/research.md +++ b/specs/006-api-lifecycle-management/research.md @@ -47,6 +47,15 @@ auto-registered lab. A dedicated "system discovery" backend module, separate fro `autoApiRegistration.ts` — rejected as unnecessary duplication; the file-glob/x-* extraction mechanism is identical, only the target entity kind differs. +**Gotcha (found during Step 4 verification)**: a synthesized `System` entity needs the same +`backstage.io/managed-by-location` / `backstage.io/managed-by-origin-location` annotations the +generated `API` entities get, even though it has no real spec file behind it. Without them, +Backstage's own catalog processing treats it as a location-less orphan and silently drops it a +cycle or two later — the `System` would appear right after a restart, then vanish, with no error +surfaced anywhere the learner would think to look. `buildSystemEntity()` sets both annotations to a +synthetic `synthetic:` value (never resolved as a real location, just present so +processing doesn't reject the entity). + ## R2: Identifying and displaying the "latest" version **Decision**: Read each version's `info.version` (a field every OpenAPI/AsyncAPI spec already has) diff --git a/specs/006-api-lifecycle-management/tasks.md b/specs/006-api-lifecycle-management/tasks.md index 5a12c00..6a0a14d 100644 --- a/specs/006-api-lifecycle-management/tasks.md +++ b/specs/006-api-lifecycle-management/tasks.md @@ -75,6 +75,12 @@ without both API versions existing and being groupable. files that changed this cycle) and include one synthesized `System` `DeferredEntity` per distinct slug in both the first-run `full` mutation and any triggered `delta` mutation (research.md R1a, depends on T005) +- [X] T006a Fix `buildSystemEntity()` in `autoApiRegistration.ts` to set + `backstage.io/managed-by-location` / `backstage.io/managed-by-origin-location` annotations + (a synthetic `synthetic:` value) on every synthesized `System` entity — without + them, catalog processing treats the entity as a location-less orphan and deletes it a cycle + or two after each restart (found during Step 4 verification; research.md R1a "Gotcha"; + resolves checklists/issues.md Run 1, depends on T006) - [X] T007 [P] Fix `normalizeConfig()`'s `rootPath` resolution in `autoApiRegistration.ts` so a user-supplied relative `rootPath` resolves against `packages/backend`'s own directory (the same anchor `defaultRootPath()` already uses), not the process's `cwd` — required for a @@ -150,6 +156,11 @@ other. - [X] T015 [US2] Implement `getVersionString()`/`getVersion()`/`compareVersions()`/`findLatest()` in `versionUtils.ts`: parse `info.version` out of `spec.definition` (already a placeholder-resolved plain string) via `js-yaml`, never a catalog annotation (research.md R2) +- [X] T015a Fix `versionUtils.ts`'s `js-yaml` import: `import yaml from 'js-yaml'` (a default + import) fails to resolve at runtime against `js-yaml@^5` (ESM-only, no default export — + `import * as yaml from 'js-yaml'` is the pattern the `apiMocking` module (Lab 5) already + uses correctly), producing an `ESModulesLinkingWarning` and breaking the frontend compile + (found during Step 4 verification, Run 2; resolves checklists/issues.md; depends on T015) - [X] T016 [US2] Render each sibling's `v{version}` label and a "Latest" `Chip` in `ApiVersionsCard.tsx`'s `VersionRow`, computed via `findLatest()` (depends on T015) - [X] T017 [US2] Verify (quickstart.md step 6): confirm `museum-api-v2` is flagged Latest, computed @@ -253,6 +264,12 @@ reachable and never deleted. - [X] T031 [P] Update `labs/lab-04-auto-registration/README.md`'s "Adaptable Conventions" `rootPath` bullet to document the `packages/backend`-relative resolution fix (T007), pointing at Lab 6's second source as a worked example +- [X] T032a Fix `labs/lab-06-api-lifecycle-management/README.md`'s Step 2: it previously described + `app-config.yaml`'s multi-source config as "already in place" and never instructed copying + the updated `autoApiRegistration.ts` (+ new `002_add_system_slug.ts` migration) into the + backend — the missing instruction a learner would need before Step 4 can pass at all; + add the corresponding Troubleshooting entry for the location-annotation orphan symptom + (resolves checklists/issues.md Run 1, pairs with T006a) - [X] T032 Confirm `labs/lab-06-api-lifecycle-management/README.md` is linked from the root `README.md`'s Lab Series table, Getting Started tree, and Repository Structure tree, per the Constitution's Lab Structure Standards (already satisfied — no edit required) From 369861b327fa272ae7ead812dd2c46429063cea5 Mon Sep 17 00:00:00 2001 From: DawMatt Date: Mon, 6 Jul 2026 08:34:04 +1000 Subject: [PATCH 10/11] Cold start performance improvements --- labs/lab-04-auto-registration/README.md | 59 +++++++++++-- .../src/extensions/autoApiRegistration.ts | 82 +++++++++++++++++-- .../checklists/issues.md | 47 +++++++++++ .../004-lab-4-auto-registration/quickstart.md | 9 ++ specs/004-lab-4-auto-registration/research.md | 77 +++++++++++++++++ specs/004-lab-4-auto-registration/tasks.md | 48 ++++++++++- .../checklists/issues.md | 16 ++++ 7 files changed, 327 insertions(+), 11 deletions(-) diff --git a/labs/lab-04-auto-registration/README.md b/labs/lab-04-auto-registration/README.md index 0924b36..92f3b90 100644 --- a/labs/lab-04-auto-registration/README.md +++ b/labs/lab-04-auto-registration/README.md @@ -317,9 +317,34 @@ config values need to change: - `parseConcurrency` bounds how many files are parsed at once, so a large batch of simultaneous changes (e.g. checking out a branch that touches hundreds of files) doesn't spike memory or block the event loop. - -If you scale this lab up, expect restarts to be fast (cache-driven, not a full re-scan) — that's -the part most likely to surprise you the first time. +- **The scan-state cache alone doesn't make a restart *visibly* fast — the scheduler's own + persisted timer has to be handled too.** The discovery cycle runs inside a + `scheduler.scheduleTask(...)` registration, and Backstage's `SchedulerService` persists each + task's `next_run_start_at` in its own database table across restarts. If you restart within one + scheduling interval of the last run (a normal dev stop/rebuild/restart), that old persisted + timestamp is still in the future and wins, so the first cycle wouldn't fire until it elapses — + even though the cache-validated scan behind it is instant. This module follows every + `scheduleTask` call with `await scheduler.triggerTask(taskId)` specifically to force that first + cycle to run immediately on boot, regardless of what was persisted from a previous process (with + a `ConflictError` guard — see Troubleshooting below — since that trigger can legitimately lose a + race against the scheduler's own worker loop on a brand-new task). +- **The triggered run can itself fire before the provider has connected — this matters more as the + schedule gets longer.** `scheduler.scheduleTask` and the catalog engine calling this provider's + `connect(...)` are two independent, unordered startup sequences. If the triggered run happens + first, `runCycle()`'s own guard skips it ("provider not yet connected") rather than erroring. At + the lab's 30s default, the very next scheduled cycle covers for this almost immediately. At a + scaled deployment's much longer cadence (an hour or more is realistic once you're not polling a + handful of files every 30s), waiting for "the next scheduled cycle" is a real, user-visible delay + — not a rounding error. `connect()` itself also kicks off the first cycle when it fires, so + whichever of the two events (scheduler trigger vs. provider connect) happens second is the one + that actually runs it — independent of how long the configured cadence is. Because both paths can + legitimately call `runCycle()` around the same moment, it dedups concurrent calls into a single + in-flight execution rather than running two overlapping full scans. + +If you scale this lab up, expect restarts to be fast (cache-driven, not a full re-scan; triggered +immediately rather than waiting on a stale scheduler timestamp; and not dependent on winning a race +against the provider's own connection lifecycle) — that's the part most likely to surprise you the +first time. --- @@ -397,8 +422,13 @@ checks — is reused unchanged; only how bytes arrive on local disk differs. - **Nothing appears after 30+ seconds.** Check the backend logs for `auto-api-registration:default:` lines. A "skipped a cycle — provider not yet connected" - warning on the very first tick is normal (the scheduler's first run can fire slightly before - the catalog engine finishes wiring up the provider); it should not repeat. + warning on the very first tick is normal (the scheduler's startup trigger can fire slightly + before the catalog engine finishes wiring up the provider) and should be followed, within about + a second, by a real cycle triggered by the provider's own `connect()` callback — it should not + repeat, and it should not take until the next 30s tick. If entities genuinely don't appear until + the next full scheduling interval, that's a sign the `connect()`-triggered retry isn't wired up + (research.md R6 Follow-up 2) — at a scaled deployment's much longer cadence, that gap becomes a + real, noticeable wait rather than a cosmetic one. - **"Owner ... does not resolve to a known User or Group entity" for an owner you're sure exists.** Confirm that group is actually loaded in the catalog (check its entity page directly) — this error means the *catalog* doesn't know about the group yet, not that your YAML is wrong. @@ -411,6 +441,13 @@ checks — is reused unchanged; only how bytes arrive on local disk differs. which looks like the discovery mechanism itself is broken. Check the backend logs for "Unable to read url, no matching files found" lines against your `catalog.locations` URLs first; if any point at a branch other than `main` or your current branch, that's almost always the real cause. + If the group genuinely does exist and just hasn't finished loading yet — this is expected right + at cold start, since discovery now runs before org-data locations are guaranteed to have loaded + (research.md R6 Follow-up 2) — this error is transient and self-heals: a previously-errored file + is always re-validated on the next cycle (research.md R6 Follow-up 3), so it should clear itself + within one scheduling interval once the group resolves, with no need to touch the spec file or + restart. If it's still erroring after several cycles, that's when to suspect a genuinely + unresolvable owner rather than a timing issue. - **An entity you expect to see is silently missing, with no error logged.** Check the backend logs for a `Policy check failed for api:default/` warning — this means the entity was built and emitted, but failed Backstage's own entity-schema validation (for example, @@ -431,3 +468,15 @@ checks — is reused unchanged; only how bytes arrive on local disk differs. spec file has an `x-*` object, check that the object's key matches `xNamespace` exactly — `x-examplecorp` in config but `x-example-corp` (extra hyphen) in the spec file will silently fall through to defaults rather than error, since an absent `x-*` object is valid input. +- **No API entities load at all — not even hand-authored ones — and startup logs show a + `ConflictError`.** This is a backend module *init* failure, not a discovery/mapping problem: if + you see an error like `Task ... is currently running` thrown while `auto-api-registration` is + starting up, it means the module's startup call to `scheduler.triggerTask(...)` (used to force + the first discovery cycle to run immediately at boot, see "Scaling to a Real Mono-Repo" below) + lost a race against the scheduler's own background worker loop, which had already claimed that + task's first run. An uncaught error at this point in a backend module's `init()` fails the whole + `catalog` plugin's initialization, which is why the symptom looks like "the entire catalog is + broken" rather than something scoped to this module. This is different from the + `AutoApiRegistrationErrorProcessor` warnings above, which are per-entity and never take down the + catalog itself. The fix is to treat that specific `ConflictError` as expected (the run we wanted + was already happening) rather than letting it propagate. diff --git a/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts b/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts index 01b3fb3..65d102c 100644 --- a/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts +++ b/labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts @@ -33,6 +33,7 @@ import type { DatabaseService, LoggerService, RootConfigService, + SchedulerService, } from '@backstage/backend-plugin-api'; import { ANNOTATION_LOCATION, @@ -41,7 +42,7 @@ import { stringifyEntityRef, } from '@backstage/catalog-model'; import type { Entity } from '@backstage/catalog-model'; -import { InputError } from '@backstage/errors'; +import { ConflictError, InputError } from '@backstage/errors'; import type { Knex } from 'knex'; import * as scanStateCacheMigration from './autoApiRegistrationMigrations/001_scan_state_cache'; import * as systemSlugMigration from './autoApiRegistrationMigrations/002_add_system_slug'; @@ -523,6 +524,13 @@ async function mapLimit( class AutoApiRegistrationEntityProvider implements EntityProvider { private connection?: EntityProviderConnection; private hasRunOnce = false; + // Dedups concurrent callers of runCycle() into a single in-flight execution. Two independent + // triggers can legitimately race to run the *first* cycle — the scheduler's startup + // `triggerTask` call (research.md R6 Corollary) and connect() below (fired the moment the + // catalog engine actually wires this provider up, in case connect() happens after that trigger + // already found `this.connection` unset and skipped) — without this, both could kick off a + // full scan/mutation at once. + private inFlightCycle?: Promise; constructor( private readonly source: SourceConfig, @@ -538,9 +546,29 @@ class AutoApiRegistrationEntityProvider implements EntityProvider { async connect(connection: EntityProviderConnection): Promise { this.connection = connection; + if (!this.hasRunOnce) { + // The scheduler's own startup trigger may have already fired and found no connection yet + // (logged as "skipped a cycle"); rather than let that mean waiting for the next scheduled + // cadence — a real problem once a scaled-up deployment schedules this hourly rather than + // every 30s (research.md R6) — run the first cycle as soon as the provider is actually + // able to, driven by this authoritative connect() signal instead of a timing race. + this.runCycle().catch(error => + this.logger.error(`auto-api-registration:${this.source.id}: initial cycle failed`, error as Error), + ); + } } async runCycle(): Promise { + if (this.inFlightCycle) { + return this.inFlightCycle; + } + this.inFlightCycle = this.doRunCycle().finally(() => { + this.inFlightCycle = undefined; + }); + return this.inFlightCycle; + } + + private async doRunCycle(): Promise { if (!this.connection) { this.logger.warn( `auto-api-registration:${this.source.id}: skipped a cycle — provider not yet connected`, @@ -561,6 +589,14 @@ class AutoApiRegistrationEntityProvider implements EntityProvider { const changedPaths = [...currentPaths].filter(filePath => { const cached = existingByPath.get(filePath); if (!cached) return true; + // A previous registration error (e.g. an owner that didn't resolve to a known User/Group + // yet) can be caused by catalog state that has nothing to do with this file's own content — + // most commonly, this cycle running before the org-data location (teams.yaml/users.yaml) + // has finished loading, which the R6 cold-start fixes make more likely, not less. Without + // this, an errored file's unchanged mtime/hash would make it permanently invisible to + // `changedPaths`, so it would never get re-validated once the catalog state that caused the + // error resolves itself — the error becomes sticky until the file itself is touched. + if (cached.last_error) return true; const stat = fs.statSync(filePath); return stat.mtimeMs !== cached.mtime_ms; }); @@ -576,8 +612,10 @@ class AutoApiRegistrationEntityProvider implements EntityProvider { const contents = fs.readFileSync(filePath, 'utf8'); const hash = sha1(contents); - if (cached && cached.content_hash === hash) { - // mtime touched, content unchanged — refresh mtime only, no mutation needed. + if (cached && cached.content_hash === hash && !cached.last_error) { + // mtime touched, content unchanged, and this file registered cleanly last time — + // refresh mtime only, no mutation needed. A previously-errored row falls through to full + // re-validation below even with an unchanged hash (see the `changedPaths` filter above). upserts.push({ ...cached, mtime_ms: stat.mtimeMs }); return; } @@ -733,6 +771,27 @@ class AutoApiRegistrationErrorProcessor implements CatalogProcessor { } } +// Forces a just-registered task's first run to happen immediately instead of waiting out a +// `next_run_start_at` persisted from a previous process (research.md R6 Corollary). On a +// brand-new task, the scheduler's own worker loop may already have claimed the run before this +// call reaches the database — that's a benign lost race (the run we wanted is already happening), +// surfaced as `ConflictError`, and must not be allowed to fail backend module init. +async function triggerTaskIgnoringConflict( + scheduler: SchedulerService, + taskId: string, + logger: LoggerService, +): Promise { + try { + await scheduler.triggerTask(taskId); + } catch (error) { + if (error instanceof ConflictError) { + logger.debug(`auto-api-registration: ${taskId} was already running when triggered at startup`); + return; + } + throw error; + } +} + // --------------------------------------------------------------------------------------------- // Backend module registration (following packages/backend/src/extensions/permissionPolicy.ts) // --------------------------------------------------------------------------------------------- @@ -772,8 +831,9 @@ export default createBackendModule({ const provider = providers[index]; if (source.mode === 'poll') { + const taskId = `auto-api-registration:${source.id}:poll`; await scheduler.scheduleTask({ - id: `auto-api-registration:${source.id}:poll`, + id: taskId, frequency: { seconds: source.scheduleFrequencySeconds }, timeout: { seconds: Math.max(source.scheduleFrequencySeconds * 2, 30) }, fn: async () => { @@ -784,13 +844,22 @@ export default createBackendModule({ } }, }); + // The scheduler persists `next_run_start_at` across restarts, so a backend restart + // within one `scheduleFrequencySeconds` window of the last run would otherwise wait + // out the remainder of that persisted timer before the first cycle fires. Force an + // immediate run so entities are visible right after cold start. On a brand-new task, + // `triggerTask` can lose a race against the scheduler's own worker loop (which already + // scheduled the first run for "now") and throw `ConflictError` — that just means the + // run we wanted already started, so it's swallowed rather than failing catalog init. + await triggerTaskIgnoringConflict(scheduler, taskId, logger); } else { // Steady-state discovery signal at real-world scale: a watcher triggers a rescan of // just this source on add/change/unlink, coalesced by chokidar's own event batching. // `schedule.frequencySeconds` is ignored in watch mode in favor of the much longer // `reconciliation.frequencySeconds` safety-net sweep below. + const taskId = `auto-api-registration:${source.id}:reconciliation`; await scheduler.scheduleTask({ - id: `auto-api-registration:${source.id}:reconciliation`, + id: taskId, frequency: { seconds: source.reconciliationFrequencySeconds }, timeout: { seconds: Math.max(source.reconciliationFrequencySeconds / 2, 60) }, fn: async () => { @@ -801,6 +870,9 @@ export default createBackendModule({ } }, }); + // Same cold-start rationale as the poll-mode task above: don't wait out a persisted + // reconciliation timer before the first sweep runs. + await triggerTaskIgnoringConflict(scheduler, taskId, logger); const watcher = chokidar.watch(source.patterns, { cwd: source.rootPath, diff --git a/specs/004-lab-4-auto-registration/checklists/issues.md b/specs/004-lab-4-auto-registration/checklists/issues.md index d6cfebe..60e9f6c 100644 --- a/specs/004-lab-4-auto-registration/checklists/issues.md +++ b/specs/004-lab-4-auto-registration/checklists/issues.md @@ -1,3 +1,50 @@ +## Run 3 - 2026/07/06 + +- [X] After the Run 2 fix, waited 2 scheduled poll cycles from a clean cold start and the + auto-registered APIs (`scalar-galaxy`, `precedence-demo-api`) still hadn't loaded. + + **Resolved** (specs/004-lab-4-auto-registration/tasks.md T049–T051): root cause was that making + the first discovery cycle run as early as possible (research.md R6 Follow-up 2, the `connect()` + fix from Run 2) made it *more* likely to race ahead of org-data loading — `teams.yaml` is fetched + from a remote URL and can easily still be loading when the very first cycle runs a fraction of a + second after backend startup. When that race is lost, `scalar-galaxy`'s owner + (`group:default/platform-team`) doesn't resolve yet, so the file registers as an error entity — + and the existing mtime/content-hash change-detection logic then permanently excluded that + unchanged file from re-validation on every later cycle, because owner-resolution failures were + never distinguished from "nothing to do here" the way file changes are. Confirmed + experimentally: cycle 1 (immediately after `connect()`) failed with "Owner ... does not resolve", + while `platform-team` was independently confirmed resolvable in the catalog only ~3 seconds + later — but the error never cleared on cycle 2 (the next 30s tick) because nothing about the + file itself had changed. Fixed by always including a previously-errored cached row in + `changedPaths` (and skipping the "unchanged, skip re-validation" short-circuit for such rows) + regardless of mtime/hash, so a registration error is retried every cycle until it resolves. + Verified via a clean `yarn start`: cycle 1 still shows the transient owner error (expected, + logged), but by the next 30s cycle both `scalar-galaxy` and `precedence-demo-api` are present + in the catalog with `spec.owner: group:default/platform-team` and no registration-error + annotation. + +## Run 2 - 2026/07/06 + +- [X] The cold start fix (research.md R6 Corollary, tasks.md T015) broke all API catalog item + loading — not just auto-registered entities, everything. + + **Resolved** (specs/004-lab-4-auto-registration/tasks.md T042–T044): root cause was an + unguarded `await scheduler.triggerTask(taskId)` added right after each + `scheduler.scheduleTask(...)` call to force the first discovery cycle to run immediately at + boot instead of waiting out a `next_run_start_at` persisted from a previous process. On a + brand-new task (fresh database), the scheduler's own background worker loop can already have + claimed that task's first run by the time the module's own `triggerTask` call reaches the + database — `triggerTask` then throws `ConflictError` ("Task ... is currently running"). That + throw happened inside the `auto-api-registration` backend module's `init()`, which runs as part + of the `catalog` plugin's own init chain in the new backend system, so the uncaught error failed + catalog plugin initialization entirely — hence *no* API entities loading, hand-authored or + auto-registered. Fixed by wrapping both `triggerTask` calls in a helper + (`triggerTaskIgnoringConflict`) that swallows `ConflictError` specifically (it just means the + run we wanted was already happening) while letting any other error propagate. Verified via a + clean `yarn start` against a fresh database: no `ConflictError` at startup, every previously + registered entity loads, and auto-registered APIs are visible immediately rather than ~30s + after the app-config locations. + ## Run 1 - 2026/07/03 - [X] No APIs were available in the catalog after starting backstage. Relevant log entries are included below. diff --git a/specs/004-lab-4-auto-registration/quickstart.md b/specs/004-lab-4-auto-registration/quickstart.md index 799c3cd..eb480eb 100644 --- a/specs/004-lab-4-auto-registration/quickstart.md +++ b/specs/004-lab-4-auto-registration/quickstart.md @@ -67,6 +67,15 @@ The README includes a "Scaling to a real mono-repo" note pointing learners at: - That restart behavior at scale relies on the persisted cache, not a full re-scan — this is called out explicitly since it's the part most likely to surprise a learner who scales this lab up and then wonders why a restart is instant instead of taking minutes. +- That the cache alone isn't sufficient for a *visibly instant* restart: the discovery cycle is + still driven by `scheduler.scheduleTask`, and Backstage's scheduler persists each task's + `next_run_start_at` across restarts. Without an explicit `scheduler.triggerTask(taskId)` call + right after registering the task, a backend restarted within one scheduling interval of its + last run would sit idle — entities visibly missing — until that old persisted timer elapses, + even though the cache-validated scan behind it would have been instant. `triggerTask` is what + actually makes the first cycle run at boot (research.md R6 Corollary); this is why the lab's own + cold start now shows auto-registered APIs immediately instead of ~30s after the app-config + locations appear. A second "Scaling to multiple source repositories" note (research.md R7) covers: - The `autoApiRegistration.sources[]` config list — the lab's own `app-config.yaml` uses the flat diff --git a/specs/004-lab-4-auto-registration/research.md b/specs/004-lab-4-auto-registration/research.md index c950573..a48a53a 100644 --- a/specs/004-lab-4-auto-registration/research.md +++ b/specs/004-lab-4-auto-registration/research.md @@ -266,6 +266,83 @@ reintroduce the hand-maintained-duplicate-file problem Lab 4 exists to eliminate the discovery tool to have write access to a repo it should only need to read — a materially larger and riskier permission footprint at real-world scale. +**Corollary — the scan-state cache alone does not make a cold restart show entities quickly; the +scheduler's own persisted timer must also be accounted for.** The scan-state cache (above) removes +re-parse cost from a restart, but the discovery *cycle* is still driven by a `scheduler.scheduleTask` +registration (`autoApiRegistration.ts`), and Backstage's `SchedulerService` persists each task's +`next_run_start_at` in its own database table — separate from, and orthogonal to, this module's +scan-state cache. That persisted timestamp **survives a backend restart**: on re-registration, the +scheduler's `TaskWorker.persistTask()` upserts `next_run_start_at` to `min(now + frequency, +)`. If the restart happens within one `scheduleFrequencySeconds` +window of the last run before shutdown (the common case for a dev stop/rebuild/restart cycle, and +easy to trigger in the lab's default 30s-poll config), the previously persisted timestamp is still +in the future and wins the `min()`, so the first discovery cycle does not fire until that original +schedule elapses — up to a full `scheduleFrequencySeconds`/`reconciliation.frequencySeconds` after +the backend comes back up, regardless of how fast the cache-validated scan itself would have been. +This is what actually causes the "no APIs, then app-config locations, then everything else ~30s +later" cold-start sequence, not a lack of scan-state persistence. + +**Decision**: immediately after each `scheduler.scheduleTask(...)` call, call +`await scheduler.triggerTask(taskId)` to force that task's first run right away, independent of +whatever `next_run_start_at` was persisted from a previous process. This is done for both the +poll-mode task and the watch-mode reconciliation task. The scan-state cache then does its job on +that immediate first run — unchanged files are skipped cheaply — so the net effect is that +auto-registered entities become visible essentially as soon as the backend finishes booting, not +up to one scheduling interval later. + +**Follow-up 1 — `triggerTask` can lose a race against the scheduler's own worker loop.** On a +brand-new task (no persisted row yet), `persistTask()` already sets `next_run_start_at` to "now", +so the scheduler's background worker can claim and start that first run before the module's own +`triggerTask` call reaches the database. `triggerTask` then throws `ConflictError` ("Task ... is +currently running"). Left unguarded, that throw happens inside the backend module's `init()` — +part of the `catalog` plugin's own init chain in the new backend system — and an uncaught error +there fails catalog plugin initialization *entirely*, not just this module (see checklists/issues.md +Run 2). **Decision**: wrap every `triggerTask` call in a `triggerTaskIgnoringConflict` helper that +swallows `ConflictError` specifically (it means the run we wanted is already happening) and +re-throws anything else. + +**Follow-up 2 — `triggerTask` can also fire before the `EntityProvider` has connected, wasting the +whole point of triggering it.** The scheduler registering a task and the catalog engine calling +this provider's `connect(...)` are two independent, unordered startup sequences. If the triggered +run executes first, `runCycle()`'s existing `!this.connection` guard logs "skipped a cycle — +provider not yet connected" and returns without discovering anything — and without the +`triggerTask` fix, the *only* remaining path to run again is the next natural scheduled cycle. That +was tolerable when a lab default polls every 30s, but is a real, user-visible problem once a scaled +deployment schedules this hourly or longer (R6 above) — "wait for the next scheduled check" would +mean genuinely waiting up to an hour for entities that could have been ready in under a second. +**Decision**: make `connect()` itself kick off the first cycle (`if (!this.hasRunOnce) { this.runCycle()... }`) +rather than relying on winning a timing race with the scheduler's trigger. `connect()` is the +authoritative, non-racy signal that the provider is actually able to run — driving the first cycle +from it, in addition to the scheduler-side `triggerTask`, means whichever of the two events happens +second is the one that actually does the work, regardless of which one that is or how long the +configured cadence is. Because both paths can now legitimately call `runCycle()` around the same +time, `runCycle()` was changed to dedup concurrent invocations into one shared in-flight promise +(`inFlightCycle`) rather than allowing two overlapping full scans. + +**Follow-up 3 — running the first cycle as early as possible (Follow-up 2) races the discovery +cycle against org-data loading, and a resulting owner-validation failure was permanently sticky.** +Making the first cycle run at the earliest possible moment (Follow-up 2) makes it *more* likely, +not less, that it runs before an org-data location (e.g. `teams.yaml`, itself fetched from a remote +URL) has finished loading `Group`/`User` entities into the catalog. When that happens, `mapCandidate` +correctly rejects the owner reference (R4) and the file is registered as an error entity — but the +existing change-detection logic (`changedPaths`, above) only re-examines a file when its `mtimeMs` +differs from the cached row, or (a second check) when its content hash differs. A file whose content +never changes was therefore excluded from `changedPaths` on every subsequent cycle even after the +org data it depends on had loaded moments later — the registration error became permanently stuck +until the file itself was touched or the scan-state cache was wiped. Confirmed experimentally: a +clean restart reliably produced the error on cycle 1 (before `platform-team` had loaded) and never +self-corrected on cycle 2 even though `platform-team` was resolvable within ~3 seconds of the +failed attempt. + +**Decision**: a cached row with `last_error` set is now *always* included in `changedPaths` +regardless of mtime, and the mtime-unchanged/hash-unchanged short-circuit inside the mapping loop +is skipped for such rows too — so a previously-errored file is fully re-validated (owner, +visibility, precedence, collision) on every subsequent cycle until it either succeeds or the file +itself is fixed. This is a strict correctness improvement independent of cold-start timing: an +owner/visibility validation failure can be caused by catalog state that changes for reasons having +nothing to do with a restart at all (a team added, renamed, or fixed after the fact), and it +shouldn't require touching the spec file to pick that up. + **Problem 3 — polling a 1GB tree every 30s doesn't scale as the primary discovery loop.** Even with ignore patterns (R2) and streaming (R2), a full `fast-glob` walk of a very large tree still costs real I/O, and running that walk every 30s regardless of whether anything changed is wasted diff --git a/specs/004-lab-4-auto-registration/tasks.md b/specs/004-lab-4-auto-registration/tasks.md index 2ae6c85..31e6ff4 100644 --- a/specs/004-lab-4-auto-registration/tasks.md +++ b/specs/004-lab-4-auto-registration/tasks.md @@ -70,7 +70,7 @@ This is a tutorial-lab project (not a generic web/mobile app). Two path roots ar - [X] T012 [US1] Implement candidate entity mapping using only natively-homed fields — `info.title` slugified → `metadata.name`, verbatim `info.title` → `metadata.title`, `info.description` → `metadata.description`, native `tags[].name` → `metadata.tags` (default `[]`), `openapi`/`asyncapi` presence → `spec.type`, `spec.definition.$text` → source file path/URL, `backstage.io/managed-by-location` annotation → source file path — with `spec.owner`/`spec.lifecycle` set to the per-source `defaultOwner` constant and the fixed `experimental` default (no `x-*` reading yet) in `labs/lab-01-base-backstage/backstage/packages/backend/src/extensions/autoApiRegistration.ts` (data-model.md Catalog API Entity table, depends on T008) - [X] T013 [US1] Implement the first-run full mutation: glob scan → parse + shape-check → map candidates (T012) → build scan-state cache rows → emit one `type: 'full'` `EntityProvider` mutation in `labs/lab-01-base-backstage/backstage/packages/backend/src/extensions/autoApiRegistration.ts` (research.md R1/R6, depends on T009, T012) - [X] T014 [US1] Implement subsequent-cycle delta mutations: diff current scan against scan-state cache rows, emit `type: 'delta'` (`added`/`removed`) mutations for changed/new/removed files only, update cache rows in `labs/lab-01-base-backstage/backstage/packages/backend/src/extensions/autoApiRegistration.ts` (FR-002/003/004, research.md R6 Problem 1, depends on T013) -- [X] T015 [US1] Wire the `coreServices.scheduler` poll loop (`schedule.frequencySeconds`, default 30s) to run the discovery cycle (T013 first run, then T014) per configured source in `labs/lab-01-base-backstage/backstage/packages/backend/src/extensions/autoApiRegistration.ts` (research.md R2, depends on T014) +- [X] T015 [US1] Wire the `coreServices.scheduler` poll loop (`schedule.frequencySeconds`, default 30s) to run the discovery cycle (T013 first run, then T014) per configured source in `labs/lab-01-base-backstage/backstage/packages/backend/src/extensions/autoApiRegistration.ts` (research.md R2, depends on T014); follow every `scheduler.scheduleTask(...)` call (poll task and watch-mode reconciliation task) with `await scheduler.triggerTask(taskId)` so the first cycle runs immediately on backend startup rather than waiting out the scheduler's own persisted `next_run_start_at` from a prior process (research.md R6 Corollary) - [X] T016 [US1] Vendor the Scalar Galaxy API to `labs/lab-04-auto-registration/apis/galaxy/galaxy-openapi.yaml` (trimmed copy of the MIT-licensed `https://cdn.jsdelivr.net/npm/@scalar/galaxy/dist/3.1.yaml`, no `x-examplecorp` object yet, no pre-existing `catalog-info.yaml`) — used to manually verify create/update/remove for this story (research.md R5) - [X] T017 [US1] Manually verify against `labs/lab-01-base-backstage/backstage/` (quickstart.md steps 5–7): Galaxy API appears within one poll cycle with no hand-authored catalog file (SC-001); editing `info.description` updates the entity in place, not a duplicate (FR-003); renaming the file out of the discovery pattern retracts the entity as active (FR-004/SC-005) @@ -149,6 +149,52 @@ This is a tutorial-lab project (not a generic web/mobile app). Two path roots ar --- +## Phase 8: Bug Fixes (from checklists/issues.md — Run 2, 2026/07/06) + +**Purpose**: Fix a regression introduced by the T015 cold-start fix (research.md R6 Corollary): "The cold start fix has broken all API catalog item loading." Root cause analysis: + +- T015 added `await scheduler.triggerTask(taskId)` immediately after every `scheduler.scheduleTask(...)` call, to force the first discovery cycle to run at boot instead of waiting out a persisted `next_run_start_at` from a prior process. +- `triggerTask` (`@backstage/backend-defaults` `TaskWorker.trigger`) does `UPDATE ... WHERE id = taskId AND current_run_ticket IS NULL`, and throws a `ConflictError` if zero rows update — i.e. if the task is already running. +- On a brand-new task (fresh database, never scheduled before), `persistTask()` sets `next_run_start_at` to "now" already (no `initialDelay` is configured), so the scheduler's own background worker loop can win the race and start executing the task before the module's explicit `triggerTask` call reaches the database — the two calls are not sequenced against each other. +- That unguarded `throw` happens inside the `auto-api-registration` backend module's `init()`, which runs as part of the `catalog` plugin's init chain in the new backend system (`catalogProcessingExtensionPoint`). An uncaught rejection there fails that plugin's initialization outright, which is why *all* API catalog items stopped loading (not just the auto-registered ones) — not a defect in discovery/mapping logic itself. + +- [X] T042 Guard both `scheduler.triggerTask(taskId)` calls (poll-mode task and watch-mode reconciliation task) in `labs/lab-01-base-backstage/backstage/packages/backend/src/extensions/autoApiRegistration.ts` (and the mirrored reference copy in `labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts`) with a try/catch that swallows `ConflictError` from `@backstage/errors` (it means the task already started on its own — the desired outcome) while letting any other error propagate, so a lost race can no longer crash catalog plugin init (research.md R6 Corollary, depends on T015) +- [X] T043 [P] Add a troubleshooting entry to `labs/lab-04-auto-registration/README.md`'s Troubleshooting section covering this failure mode (all catalog entities missing, not just auto-registered ones, immediately after adding/changing the cold-start `triggerTask` call) and how to recognize it in backend logs (a `ConflictError`/"is currently running" error thrown during backend module init, not an `AutoApiRegistrationErrorProcessor` warning) (depends on T042) +- [X] T044 Restart `labs/lab-01-base-backstage/backstage/` from a clean `yarn start` (fresh database) after T042 and confirm via backend logs and the catalog UI: no `ConflictError` is thrown during startup, all previously-registered entities (museum/streetlights/train-travel/scalar-galaxy/precedence-demo-api plus org entities) load normally, and the auto-registered APIs are visible immediately rather than ~30s after the app-config locations (depends on T042) +- [X] T045 Update `specs/004-lab-4-auto-registration/checklists/issues.md`: add a Run 2 entry documenting this regression, resolved with a one-line pointer to T042–T044 + +**Checkpoint**: Cold start shows every entity (auto-registered and hand-authored) immediately, with no catalog plugin init failure — the T015 fix's original goal is preserved without the regression. + +--- + +## Phase 9: Tighten Up Cold-Start Timing (research.md R6 Follow-up 2) + +**Purpose**: T042 stopped the `triggerTask` race from crashing catalog init, but didn't close a remaining timing gap: if the triggered run executes *before* the catalog engine calls this provider's `connect(...)`, `runCycle()`'s existing `!this.connection` guard skips it (logged as "skipped a cycle — provider not yet connected"), and without a further fix, the only remaining path to run again is the next naturally scheduled cycle. At the lab's 30s default that's a minor annoyance; at a scaled deployment's much longer cadence (R6 — e.g. hourly), that's a genuinely noticeable wait for entities that could have been ready in under a second. + +- [X] T046 Change `AutoApiRegistrationEntityProvider.connect()` in `labs/lab-01-base-backstage/backstage/packages/backend/src/extensions/autoApiRegistration.ts` (and the mirrored reference copy in `labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts`) to kick off `runCycle()` itself when `!this.hasRunOnce`, so the first cycle is driven by the provider's own authoritative "I am now connected" signal rather than relying on winning a timing race with the scheduler's `triggerTask` call (research.md R6 Follow-up 2, depends on T042) +- [X] T047 Add an `inFlightCycle` dedup guard to `runCycle()` (rename the existing body to a private `doRunCycle()`, make `runCycle()` a thin wrapper that returns the shared in-flight promise) in the same two files, since T046 means the scheduler-triggered path and the connect()-triggered path can now legitimately race to call `runCycle()` around the same moment and must not both run a full scan concurrently (depends on T046) +- [X] T048 Restart `labs/lab-01-base-backstage/backstage/` from a clean `yarn start` (fresh database) after T046/T047 and confirm via backend logs: the "skipped a cycle — provider not yet connected" line (if it still occurs) is followed by a successful cycle within about a second (via `connect()`), not 30+ seconds later; no duplicate full-mutation logs; no `ConflictError` (depends on T047) + +**Checkpoint**: The first discovery cycle runs as soon as the provider is structurally able to, independent of the scheduler's own cadence — closing the gap that would otherwise scale badly on a real deployment's much longer `scheduleFrequencySeconds`/`reconciliation.frequencySeconds`. + +--- + +## Phase 10: Bug Fixes (from checklists/issues.md — Run 3, 2026/07/06) + +**Purpose**: Fix "waited 2 scheduled poll cycles and the auto-registered APIs still hadn't loaded," a direct consequence of T046 (research.md R6 Follow-up 3). Root cause analysis: + +- T046 made the first discovery cycle run as early as possible via `connect()`, which makes it *more* likely to race ahead of org-data loading (`teams.yaml`, fetched from a remote URL) than the old "wait for the next scheduled tick" behavior did. +- When that race is lost, an auto-registered file's owner doesn't resolve yet, and it's correctly registered as an error entity (R4) — a transient condition caused by catalog state, not a defect in the file itself. +- The existing `changedPaths` change-detection logic (T009, research.md R6 Problem 2) only re-examines a file when its `mtimeMs`/content hash changes — an unchanged file that previously errored was therefore silently excluded from all future cycles, so the transient error became permanently stuck even after the org data it depended on had loaded. + +- [X] T049 Change the `changedPaths` filter in `labs/lab-01-base-backstage/backstage/packages/backend/src/extensions/autoApiRegistration.ts` (and the mirrored reference copy in `labs/lab-04-auto-registration/code/packages/backend/src/extensions/autoApiRegistration.ts`) to always include a cached row with `last_error` set, regardless of `mtimeMs`, and skip the "content hash unchanged, no re-validation needed" short-circuit inside the mapping loop for such rows, so a previously-errored file is fully re-validated every cycle until it resolves or the file itself changes (research.md R6 Follow-up 3, depends on T046) +- [X] T050 Restart `labs/lab-01-base-backstage/backstage/` from a clean `yarn start` (fresh database) and confirm via backend logs and the catalog API: cycle 1 may still show the transient "Owner ... does not resolve" error (expected, since org-data loading isn't synchronized with this module), but by the next scheduled cycle both `scalar-galaxy` and `precedence-demo-api` are present with `spec.owner: group:default/platform-team` and no registration-error annotation (depends on T049) +- [X] T051 Update `specs/004-lab-4-auto-registration/checklists/issues.md`: add a Run 3 entry documenting this regression, resolved with a one-line pointer to T049–T050 + +**Checkpoint**: A registration error caused by transient catalog state (not the file's own content) self-heals on the next cycle instead of requiring the file to be touched or the cache to be wiped — true both at cold start and any time org data changes later. + +--- + ## Dependencies & Execution Order ### Phase Dependencies diff --git a/specs/006-api-lifecycle-management/checklists/issues.md b/specs/006-api-lifecycle-management/checklists/issues.md index 0e51d95..0eb61d8 100644 --- a/specs/006-api-lifecycle-management/checklists/issues.md +++ b/specs/006-api-lifecycle-management/checklists/issues.md @@ -53,3 +53,19 @@ WARNING in ./src/modules/apiVersions/versionUtils.ts 17:21-29 re-verified the full catalog state (both Museum API versions, the `museum-api` System with `hasPart` relations, independent lifecycles, `info.version`-based Latest) is still correct after the fix. + +## Run 3 - 2026/07/06 + +- [ ] API page is now completely empty. No catalog items were loaded at all. Below is an excerpt from the logs generated by backstage. + + +``` +[app] 2026-07-05T21:51:51.208Z catalog warn auto-api-registration:default: skipped a cycle — provider not yet connected +[app] 2026-07-05T21:51:51.211Z backstage error Module auto-api-registration in Plugin 'catalog' threw an error during startup, waiting for 5 other plugins to finish before shutting down the process. Task auto-api-registration:default:poll is currently running type="initialization" cause=undefined name="ConflictError" stack="ConflictError: Task auto-api-registration:default:poll is currently running\n at Function.trigger (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-defaults/src/entrypoints/scheduler/lib/TaskWorker.ts:151:13)\n at async PluginTaskSchedulerImpl.triggerTask (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.ts:107:5)\n at async Object.init (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/packages/backend/src/extensions/autoApiRegistration.ts:792:13)\n at async (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/wiring/BackendInitializer.ts:395:19)\n at async processNode (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/lib/DependencyGraph.ts:254:22)\n at async Promise.all (index 2)\n at async processMoreNodes (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/lib/DependencyGraph.ts:246:7)\n at async DependencyGraph.parallelTopologicalTraversal (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/lib/DependencyGraph.ts:272:5)\n at async (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/wiring/BackendInitializer.ts:386:13)\n at async Promise.all (index 5)\n at async BackendInitializer.#doStart (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/wiring/BackendInitializer.ts:356:5)\n at async BackendInitializer.start (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/wiring/BackendInitializer.ts:278:12)\n at async BackstageBackend.start (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/wiring/BackstageBackend.ts:48:12)" +... +[app] 2026-07-05T21:51:51.692Z backstage error Unhandled rejection Backend startup failed due to the following errors: +[app] Module 'auto-api-registration' for plugin 'catalog' startup failed; caused by ConflictError: Task auto-api-registration:default:poll is currently running type="unhandledRejection" cause=undefined name="BackendStartupError" stack="BackendStartupError: Backend startup failed due to the following errors:\n Module 'auto-api-registration' for plugin 'catalog' startup failed; caused by ConflictError: Task auto-api-registration:default:poll is currently running\n at BackendInitializer.#doStart (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/wiring/BackendInitializer.ts:436:13)\n at async BackendInitializer.start (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/wiring/BackendInitializer.ts:278:12)\n at async BackstageBackend.start (/Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-01-base-backstage/backstage/node_modules/@backstage/backend-app-api/src/wiring/BackstageBackend.ts:48:12)" +... +[app] 2026-07-05T21:52:21.249Z catalog error auto-api-registration:default: /Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-04-auto-registration/apis/galaxy/galaxy-openapi.yaml registered as an error entity "scalar-galaxy" — Owner "group:default/platform-team" does not resolve to a known User or Group entity +[app] 2026-07-05T21:52:21.249Z catalog error auto-api-registration:default: /Users/matt/Code/DawMatt/backstage-apiportal-lab/labs/lab-04-auto-registration/apis/precedence-demo/precedence-demo-openapi.yaml registered as an error entity "precedence-demo-api" — Owner "group:default/platform-team" does not resolve to a known User or Group entity +``` \ No newline at end of file From f14797d0df091cecf6e018d2681072c7569db9ef Mon Sep 17 00:00:00 2001 From: DawMatt Date: Mon, 6 Jul 2026 08:35:14 +1000 Subject: [PATCH 11/11] Close last item --- specs/006-api-lifecycle-management/checklists/issues.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/006-api-lifecycle-management/checklists/issues.md b/specs/006-api-lifecycle-management/checklists/issues.md index 0eb61d8..8de559e 100644 --- a/specs/006-api-lifecycle-management/checklists/issues.md +++ b/specs/006-api-lifecycle-management/checklists/issues.md @@ -56,7 +56,7 @@ WARNING in ./src/modules/apiVersions/versionUtils.ts 17:21-29 ## Run 3 - 2026/07/06 -- [ ] API page is now completely empty. No catalog items were loaded at all. Below is an excerpt from the logs generated by backstage. +- [x] API page is now completely empty. No catalog items were loaded at all. Below is an excerpt from the logs generated by backstage. (Fixes recorded against feature 4) ```