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/.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/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-04-auto-registration/README.md b/labs/lab-04-auto-registration/README.md index 8ca2198..92f3b90 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. @@ -314,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. --- @@ -394,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. @@ -408,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, @@ -428,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/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-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..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,9 +42,10 @@ 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'; // --------------------------------------------------------------------------------------------- // Config @@ -118,9 +120,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 +226,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 +255,7 @@ function buildEntity(opts: { owner: string; lifecycle: string; visibility: string; + system?: string; registrationError?: string; }): Entity { const annotations: Record = { @@ -273,11 +286,37 @@ 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 { + // 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, + }, + }, + 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 +339,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 +407,7 @@ function mapCandidate(opts: { return { entityName: baseSlug, + systemSlug, entity: buildEntity({ providerName, filePath, @@ -373,6 +417,7 @@ function mapCandidate(opts: { owner, lifecycle, visibility, + system: systemSlug, }), }; } @@ -388,6 +433,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 +448,25 @@ 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". + interface MigrationModule { + up(knex: Knex): Promise; + down(knex: Knex): Promise; + } + 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]; }, }, }); @@ -468,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, @@ -483,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`, @@ -506,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; }); @@ -521,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; } @@ -584,6 +677,7 @@ class AutoApiRegistrationEntityProvider implements EntityProvider { content_hash: hash, entity_name: mapped.entityName, last_error: mapped.error ?? null, + system_slug: mapped.systemSlug ?? null, }); }); @@ -594,14 +688,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 })), }); } @@ -655,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) // --------------------------------------------------------------------------------------------- @@ -694,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 () => { @@ -706,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 () => { @@ -723,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/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 new file mode 100644 index 0000000..9ab1296 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/README.md @@ -0,0 +1,392 @@ +# 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. + +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` 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 + (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. 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," 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 + +--- + +## Prerequisites + +- **Labs 1–5 completed** — Backstage running locally with Museum, Streetlights, Train Travel, + 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 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 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): + +- **`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`. + +Both specs carry an `info.x-examplecorp` block — the same `x-` extension mechanism Lab 4 +already introduced for owner/lifecycle/visibility: + +```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 +``` + +`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 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 +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 +``` + +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 + +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 two `EntityCardBlueprint` cards to every `kind:API` entity's page — no new backend +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 + `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 + +``` +cd labs/lab-01-base-backstage/backstage +yarn start +``` + +Within one `autoApiRegistration` poll cycle, open the catalog and confirm: + +- `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** (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` (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 + +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 `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 `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. +- `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 +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'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 + +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 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. + +--- + +## Adaptable Conventions vs. Fixed Mechanics + +Adaptable — change these freely for your own APIs: + +- 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 `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, 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, 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). +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 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 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. +- **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. + +--- + +## 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. +- [`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/apis/museum-v2/museum-v2-openapi.yaml b/labs/lab-06-api-lifecycle-management/apis/museum-v2/museum-v2-openapi.yaml new file mode 100644 index 0000000..6482d82 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/apis/museum-v2/museum-v2-openapi.yaml @@ -0,0 +1,504 @@ +# 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: + # 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 + 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 + # 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 + 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/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 new file mode 100644 index 0000000..18182e1 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/ApiVersionsCard.tsx @@ -0,0 +1,142 @@ +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 { + Button, + Chip, + List, + ListItem, + ListItemText, + Typography, +} from '@material-ui/core'; +import { + RETIRED, + compareVersions, + findLatest, + getLifecycle, + getVersionString, + isRetired, + useApiSiblings, +} from './versionUtils'; + +function VersionRow({ + sibling, + isCurrent, + isLatest, +}: { + sibling: Entity; + isCurrent: boolean; + isLatest: boolean; +}) { + const version = getVersionString(sibling) ?? 'unknown'; + const lifecycle = getLifecycle(sibling); + return ( + + + {isCurrent ? ( + + {sibling.metadata.name} — v{version} (this page) + + ) : ( + + {sibling.metadata.name} — v{version} + + )} + {isLatest && } + {lifecycle && ( + + )} + + } + /> + + ); +} + +export function ApiVersionsCard() { + const { entity } = useEntity(); + const { siblings, error } = useApiSiblings(entity); + const [showRetired, setShowRetired] = useState(false); + + const system = entity.spec?.system as string | undefined; + + 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..dbd13d2 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/index.ts @@ -0,0 +1,31 @@ +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', + params: { + filter: 'kind:API', + type: 'info', + loader: async () => React.createElement(ApiVersionsCard), + }, +}); + +// 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, 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..f904dc4 --- /dev/null +++ b/labs/lab-06-api-lifecycle-management/code/packages/app/src/modules/apiVersions/versionUtils.ts @@ -0,0 +1,87 @@ +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 * as yaml from 'js-yaml'; + +export const RETIRED = 'retired'; +export const DEPRECATED = 'deprecated'; + +// 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 { + 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, 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 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; + 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/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 new file mode 100644 index 0000000..8de559e --- /dev/null +++ b/specs/006-api-lifecycle-management/checklists/issues.md @@ -0,0 +1,71 @@ +## Run 1 - 2026/07/05 + +- [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 + `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`). +``` + + **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. + +## Run 3 - 2026/07/06 + +- [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) + + +``` +[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 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..09faf84 --- /dev/null +++ b/specs/006-api-lifecycle-management/data-model.md @@ -0,0 +1,115 @@ +# Data Model: Lab 6 — API Lifecycle Management + +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. + +## Source: OpenAPI spec `info` block (one per major version) + +```yaml +openapi: 3.1.0 +info: + title: Museum API v1 + description: > + 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 +``` + +- **`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). + +## Generated entity: API (one per major version, no catalog-info.yaml) + +```yaml +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: museum-api-v1 # slugified from info.title + annotations: + example.com/visibility: private + apiportal-lab.io/managed-by: auto-api-registration:lab6-museum-api +spec: + type: openapi + lifecycle: production # copied from info.x-examplecorp.lifecycle + owner: group:default/museum-team + 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: System +metadata: + name: museum-api + annotations: + apiportal-lab.io/managed-by: auto-api-registration:lab6-museum-api +spec: + 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` (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 + +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` | 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) | +| `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 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. +- `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..4e70b81 --- /dev/null +++ b/specs/006-api-lifecycle-management/plan.md @@ -0,0 +1,208 @@ +# 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 **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). 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 + +**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`), 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 +walkthrough). + +**Target Platform**: Local development machine — Windows 10/11 and macOS 12+ (same as Labs 1–5). + +**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 +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); 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 + +*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, 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; 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. + +## 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 (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) + ├── 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 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 +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: 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 + │ # features array (same pattern as Lab 2/3) + └── 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 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 + +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..78740db --- /dev/null +++ b/specs/006-api-lifecycle-management/quickstart.md @@ -0,0 +1,53 @@ +# 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 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`. +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. +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 | +|---|---| +| 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 new file mode 100644 index 0000000..5b03415 --- /dev/null +++ b/specs/006-api-lifecycle-management/research.md @@ -0,0 +1,232 @@ +# 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. 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 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 + `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). + +## 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. + +**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) +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. 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. 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. +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 + +**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 `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 + 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 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 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"). 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. 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 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 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 +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, 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. + +**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..6a0a14d --- /dev/null +++ b/specs/006-api-lifecycle-management/tasks.md @@ -0,0 +1,341 @@ +--- + +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). Four path roots are used: + +- **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, **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 +(`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/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 — foundational work can begin. + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**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 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] 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 + 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**: 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**: 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] 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 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 computes and flags the latest version from each spec's own +`info.version` — no manually-maintained flag, no duplicated annotation. + +**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] 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 + 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. + +--- + +## Phase 5: User Story 3 - Track lifecycle state independently per version (Priority: P2) + +**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**: 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] 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**: 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`, 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] 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**: 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] 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) +- [X] T033 Run quickstart.md's full 10-step validation end-to-end against a real Backstage instance + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **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 + +### Within Each User Story + +- `versionUtils.ts` helpers before `ApiVersionsCard.tsx`/`ApiLifecycleBanner.tsx` rendering that + calls them +- Implementation before its quickstart verification task + +### Parallel Opportunities + +- 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 + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup +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**: Confirm both versions register and group correctly with zero catalog YAML +5. Demo if ready + +### Incremental Delivery + +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