diff --git a/.changeset/25159-api-docs-viewer-keys-retired.md b/.changeset/25159-api-docs-viewer-keys-retired.md new file mode 100644 index 00000000000..54cb8ce3d51 --- /dev/null +++ b/.changeset/25159-api-docs-viewer-keys-retired.md @@ -0,0 +1,47 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: the docs-viewer keys are retired — `ApiDocumentationConfig.ui` (with `ApiTestingUiConfigSchema` / `ApiTestingUiType`) and the REST API plugin config's `openApi.uiPath` / `openApi.uiFramework` are refused with a prescription naming the OpenAPI document (ADR-0049) + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. + +Three keys declared an HTML API documentation viewer, and nothing ever read any of them: `ApiDocumentationConfig.ui` (a viewer type such as `swagger-ui`, `redoc` or `scalar`, served at `ui.path`, default `/api-docs`), and on the REST API plugin config `openApi.uiPath` (default `/api/docs`) with its viewer selector `openApi.uiFramework` (default `swagger-ui`). Since the docs viewer page was taken offline, the API origin serves no HTML viewer at all, only the machine-readable OpenAPI document, so none of the three could be honoured. They are retired under ADR-0049 enforce-or-remove, with no staged window. + +**What is refused:** each key is a `retiredKey()` tombstone. Authoring it is a `tsc` error at the site, and a parse error at the key's path whose message is the prescription, for every former value including the old defaults. The value schemas `ApiTestingUiConfigSchema` and `ApiTestingUiType`, with the `ApiTestingUiConfig`, `ApiTestingUiConfigParsed` and `ApiTestingUiType` types, are no longer exported: they had no consumer once `ui` was gone. + +**What stays accepted:** every other key of `ApiDocumentationConfigSchema`, `OpenApiGenerationConfigSchema` and `RestApiPluginConfigSchema`, byte-identically, defaults included. The mounted REST surface is unchanged: none of the three keys ever reached it. + +## FROM → TO + +| you wrote | write instead | +|:--|:--| +| `ui: { type: 'swagger-ui', … }` on an `ApiDocumentationConfig` | nothing: delete the key | +| `openApi: { uiPath: '/api/docs' }` on a REST API plugin config | nothing: delete the key | +| `openApi: { uiFramework: 'redoc' }` on a REST API plugin config | nothing: delete the key | +| `import { ApiTestingUiConfigSchema, ApiTestingUiType } from '@objectstack/spec/api'` | nothing: no replacement exists | + +The one-line fix: delete `ui`, `uiPath` and `uiFramework`, and point any viewer or client generator you run yourself at the OpenAPI document, `GET /api/v1/openapi.json` under the REST server's default `api.basePath` and `api.version` (the path follows both), mounted while `api.enableOpenApi` is on. + +## Who is affected, measured + +- **objectstack** at `b93c750c7`: zero readers and zero authorings outside `packages/spec`. The only identifier hits were a local `uiPath` variable for the unrelated `/ui/view` route in `packages/rest`; nothing in the tree parses the three schemas outside their own unit tests. A tree-scoped absence pin now holds that zero. +- **objectui** at the console pin `.objectui-sha` `56cf4d8e89`: zero hits for any of the keys or exported names, so the console build at the pin is unaffected. +- **Deployed configuration** was not measured. + +## The ADR-0087 disposition + +- `RETIRED_KEYS_BY_MAJOR[18]` gains `api/ApiDocumentationConfig:ui`, `api/OpenApiGenerationConfig:uiPath` and `api/OpenApiGenerationConfig:uiFramework`; `RETIRED_DEFS_BY_MAJOR[18]` gains `api/ApiTestingUiConfig` and `api/ApiTestingUiType`. +- The D3 semantic entry `api-docs-viewer-keys-retired` and its step-18 rationale fragment carry the prescription to `os migrate meta`, the upgrade guide and `spec-changes.json`. +- No D2 conversion: neither config is a stack collection member or a stored row, so no conversion seam would ever see one. `uiPath` and `uiFramework` carried Zod defaults, but nothing parses the REST API plugin config into a stored or built artifact, so no emitted default needs tolerating. + +The retirement kit: + +- the three tombstones and their retirement records (`packages/spec/src/api/documentation.zod.ts`, `packages/spec/src/api/plugin-rest-api.zod.ts`); the two value defs deleted; `REST_API_PLUGIN.md`'s example and feature list no longer name a viewer +- pin test `docs-viewer-keys-retirement.test.ts`: `code`, `path` and the prescription at each door (each schema and `RestApiPluginConfigSchema.openApi`), the `tsc` channel, the live controls, zero holders for the retired names, the ADR-0087 registration, and a tree-scoped absence walk +- generated artifacts follow the schema: `authorable-surface/`, `authorable-defaults/`, `json-schema.manifest/`, `api-surface/`, `export-origins/`, `declaration-map/`, the strictness-ledger counts and the reference pages for `api/documentation` and `api/plugin-rest-api` diff --git a/content/docs/references/api/documentation.mdx b/content/docs/references/api/documentation.mdx index 2ecf7386c39..93eebdef0ed 100644 --- a/content/docs/references/api/documentation.mdx +++ b/content/docs/references/api/documentation.mdx @@ -1,27 +1,30 @@ --- title: Documentation schema — API Protocol reference navTitle: Documentation -description: "Provides schemas for generating interactive API documentation and testing interfaces similar to Swagger UI, Postman, etc." +description: "Provides schemas for describing API documentation and test collections." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} API Documentation & Testing Interface Protocol -Provides schemas for generating interactive API documentation and testing -interfaces similar to Swagger UI, Postman, etc. +Provides schemas for describing API documentation and test collections. Features: - OpenAPI/Swagger specification generation -- Interactive API testing playground +- API test requests and collections - API versioning and changelog - Code generation templates - Mock server configuration Architecture Alignment: -- Swagger UI: Interactive API documentation +- OpenAPI: the machine-readable API description - Postman: API testing collections -- Redoc: Documentation rendering + +The platform serves no HTML documentation viewer: the API origin publishes +the OpenAPI document (`GET {basePath}/openapi.json`) and nothing else, so +this module declares no viewer — the retired `ui` key is refused at parse +with that prescription. **Documentation Config** ```typescript @@ -30,11 +33,6 @@ const docConfig: ApiDocumentationConfig = { title: 'ObjectStack API', version: '1.0.0', servers: [{ url: 'https://api.example.com', description: 'Production' }], - ui: { - type: 'swagger-ui', - theme: 'light', - enableTryItOut: true - } } ``` @@ -45,8 +43,8 @@ const docConfig: ApiDocumentationConfig = { ## TypeScript Usage ```typescript -import { ApiChangelogEntrySchema, ApiDocumentationConfigSchema, ApiTestCollectionSchema, ApiTestRequestSchema, ApiTestingUiConfigSchema, ApiTestingUiType, CodeGenerationTemplateSchema, GeneratedApiDocumentationSchema, OpenApiSecuritySchemeSchema, OpenApiServerSchema, OpenApiSpecSchema } from '@objectstack/spec/api'; -import type { ApiChangelogEntry, ApiDocumentationConfig, ApiTestCollection, ApiTestRequest, ApiTestingUiConfig, ApiTestingUiType, CodeGenerationTemplate, GeneratedApiDocumentation, OpenApiSecurityScheme, OpenApiServer, OpenApiSpec } from '@objectstack/spec/api'; +import { ApiChangelogEntrySchema, ApiDocumentationConfigSchema, ApiTestCollectionSchema, ApiTestRequestSchema, CodeGenerationTemplateSchema, GeneratedApiDocumentationSchema, OpenApiSecuritySchemeSchema, OpenApiServerSchema, OpenApiSpecSchema } from '@objectstack/spec/api'; +import type { ApiChangelogEntry, ApiDocumentationConfig, ApiTestCollection, ApiTestRequest, CodeGenerationTemplate, GeneratedApiDocumentation, OpenApiSecurityScheme, OpenApiServer, OpenApiSpec } from '@objectstack/spec/api'; // Validate data const result = ApiChangelogEntrySchema.parse(data); @@ -90,7 +88,7 @@ const result = ApiChangelogEntrySchema.parse(data); | **version** | `string` | ✅ | API version | | **description** | `string` | optional | API description | | **servers** | `{ url: string; description?: string; variables?: Record }[]` | optional (default: `[]`) | API server URLs | -| **ui** | `{ type: Enum<'swagger-ui' \| 'redoc' \| 'rapidoc' \| 'stoplight' \| 'scalar' \| 'graphiql' \| 'postman' \| 'custom'>; path?: string; theme?: Enum<'light' \| 'dark' \| 'auto'>; enableTryItOut?: boolean; … }` | optional | Testing UI configuration | +| **ui** | `never` | optional | [REMOVED] `ApiDocumentationConfig.ui` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — nothing ever read it: no viewer was ever mounted from it, and the platform serves no HTML API documentation viewer at all, only the machine-readable OpenAPI document. Delete the key. The OpenAPI document is served at `GET /api/v1/openapi.json` under the REST server's default `api.basePath` and `api.version` (the path follows both) while `api.enableOpenApi` is on; point any viewer or client generator you run yourself at that document. | | **generateOpenApi** | `boolean` | optional (default: `true`) | Generate OpenAPI 3.0 specification | | **generateTestCollections** | `boolean` | optional (default: `true`) | Generate API test collections | | **testCollections** | `{ name: string; description?: string; variables?: Record; requests: object[]; … }[]` | optional (default: `[]`) | Predefined test collections | @@ -111,23 +109,6 @@ const result = ApiChangelogEntrySchema.parse(data); | **description** | `string` | optional | Server description | | **variables** | `Record` | optional | URL template variables | -### Nested Shape: `ApiDocumentationConfig.ui` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'swagger-ui' \| 'redoc' \| 'rapidoc' \| 'stoplight' \| 'scalar' \| 'graphiql' \| 'postman' \| 'custom'>` | ✅ | Testing UI implementation | -| **path** | `string` | optional (default: `"/api-docs"`) | URL path for documentation UI | -| **theme** | `Enum<'light' \| 'dark' \| 'auto'>` | optional (default: `"light"`) | UI color theme | -| **enableTryItOut** | `boolean` | optional (default: `true`) | Enable interactive API testing | -| **enableFilter** | `boolean` | optional (default: `true`) | Enable endpoint filtering | -| **enableCors** | `boolean` | optional (default: `true`) | Enable CORS for browser testing | -| **defaultModelsExpandDepth** | `integer` | optional (default: `1`) | Default expand depth for schemas (-1 = fully expand) | -| **displayRequestDuration** | `boolean` | optional (default: `true`) | Show request duration | -| **syntaxHighlighting** | `boolean` | optional (default: `true`) | Enable syntax highlighting | -| **customCssUrl** | `string` | optional | Custom CSS stylesheet URL | -| **customJsUrl** | `string` | optional | Custom JavaScript URL | -| **layout** | `{ showExtensions?: boolean; showCommonExtensions?: boolean; deepLinking?: boolean; displayOperationId?: boolean; … }` | optional | Layout configuration | - ### Nested Shape: `ApiDocumentationConfig.testCollections[number]` | Property | Type | Required | Description | @@ -218,57 +199,6 @@ const result = ApiChangelogEntrySchema.parse(data); | **expectedResponse** | `{ statusCode: integer; body?: any }` | optional | Expected response for validation | ---- - -## ApiTestingUiConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'swagger-ui' \| 'redoc' \| 'rapidoc' \| 'stoplight' \| 'scalar' \| 'graphiql' \| 'postman' \| 'custom'>` | ✅ | Testing UI implementation | -| **path** | `string` | optional (default: `"/api-docs"`) | URL path for documentation UI | -| **theme** | `Enum<'light' \| 'dark' \| 'auto'>` | optional (default: `"light"`) | UI color theme | -| **enableTryItOut** | `boolean` | optional (default: `true`) | Enable interactive API testing | -| **enableFilter** | `boolean` | optional (default: `true`) | Enable endpoint filtering | -| **enableCors** | `boolean` | optional (default: `true`) | Enable CORS for browser testing | -| **defaultModelsExpandDepth** | `integer` | optional (default: `1`) | Default expand depth for schemas (-1 = fully expand) | -| **displayRequestDuration** | `boolean` | optional (default: `true`) | Show request duration | -| **syntaxHighlighting** | `boolean` | optional (default: `true`) | Enable syntax highlighting | -| **customCssUrl** | `string` | optional | Custom CSS stylesheet URL | -| **customJsUrl** | `string` | optional | Custom JavaScript URL | -| **layout** | `{ showExtensions?: boolean; showCommonExtensions?: boolean; deepLinking?: boolean; displayOperationId?: boolean; … }` | optional | Layout configuration | - -### Nested Shape: `ApiTestingUiConfig.layout` - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **showExtensions** | `boolean` | optional (default: `false`) | Show vendor extensions | -| **showCommonExtensions** | `boolean` | optional (default: `false`) | Show common extensions | -| **deepLinking** | `boolean` | optional (default: `true`) | Enable deep linking | -| **displayOperationId** | `boolean` | optional (default: `false`) | Display operation IDs | -| **defaultModelRendering** | `Enum<'example' \| 'model'>` | optional (default: `"example"`) | Default model rendering mode | -| **defaultModelsExpandDepth** | `integer` | optional (default: `1`) | Models expand depth | -| **defaultModelExpandDepth** | `integer` | optional (default: `1`) | Single model expand depth | -| **docExpansion** | `Enum<'list' \| 'full' \| 'none'>` | optional (default: `"list"`) | Documentation expansion mode | - - ---- - -## ApiTestingUiType - -### Allowed Values - -* `swagger-ui` -* `redoc` -* `rapidoc` -* `stoplight` -* `scalar` -* `graphiql` -* `postman` -* `custom` - - --- ## CodeGenerationTemplate diff --git a/content/docs/references/api/plugin-rest-api.mdx b/content/docs/references/api/plugin-rest-api.mdx index 0af82eb9b56..7cfa92fcc8c 100644 --- a/content/docs/references/api/plugin-rest-api.mdx +++ b/content/docs/references/api/plugin-rest-api.mdx @@ -104,8 +104,8 @@ const result = ErrorHandlingConfigSchema.parse(data); | **description** | `string` | optional | API description | | **apiVersion** | `string` | optional (default: `"1.0.0"`) | API version | | **outputPath** | `string` | optional (default: `"/api/docs/openapi.json"`) | URL path to serve OpenAPI JSON | -| **uiPath** | `string` | optional (default: `"/api/docs"`) | URL path to serve documentation UI | -| **uiFramework** | `Enum<'swagger-ui' \| 'redoc' \| 'rapidoc' \| 'elements'>` | optional (default: `"swagger-ui"`) | Documentation UI framework | +| **uiPath** | `never` | optional | [REMOVED] `RestApiPluginConfig.openApi.uiPath` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — nothing ever read it: no documentation viewer was ever served at its path, the `/api/docs` default included, and the platform serves no HTML API documentation viewer at all, only the machine-readable OpenAPI document. Delete the key. The OpenAPI document is served at `GET /api/v1/openapi.json` under the REST server's default `api.basePath` and `api.version` (the path follows both) while `api.enableOpenApi` is on; point any viewer or client generator you run yourself at that document. | +| **uiFramework** | `never` | optional | [REMOVED] `RestApiPluginConfig.openApi.uiFramework` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — nothing ever read it: no `swagger-ui`, `redoc`, `rapidoc` or `elements` viewer was ever rendered from it, and the platform serves no HTML API documentation viewer at all, only the machine-readable OpenAPI document. Delete the key. The OpenAPI document is served at `GET /api/v1/openapi.json` under the REST server's default `api.basePath` and `api.version` (the path follows both) while `api.enableOpenApi` is on; point any viewer or client generator you run yourself at that document. | | **includeInternal** | `boolean` | optional (default: `false`) | Include internal endpoints in documentation | | **generateSchemas** | `boolean` | optional (default: `true`) | Auto-generate schemas from Zod definitions | | **includeExamples** | `boolean` | optional (default: `true`) | Include request/response examples | @@ -279,8 +279,8 @@ const result = ErrorHandlingConfigSchema.parse(data); | **description** | `string` | optional | API description | | **apiVersion** | `string` | optional (default: `"1.0.0"`) | API version | | **outputPath** | `string` | optional (default: `"/api/docs/openapi.json"`) | URL path to serve OpenAPI JSON | -| **uiPath** | `string` | optional (default: `"/api/docs"`) | URL path to serve documentation UI | -| **uiFramework** | `Enum<'swagger-ui' \| 'redoc' \| 'rapidoc' \| 'elements'>` | optional (default: `"swagger-ui"`) | Documentation UI framework | +| **uiPath** | `never` | optional | [REMOVED] `RestApiPluginConfig.openApi.uiPath` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — nothing ever read it: no documentation viewer was ever served at its path, the `/api/docs` default included, and the platform serves no HTML API documentation viewer at all, only the machine-readable OpenAPI document. Delete the key. The OpenAPI document is served at `GET /api/v1/openapi.json` under the REST server's default `api.basePath` and `api.version` (the path follows both) while `api.enableOpenApi` is on; point any viewer or client generator you run yourself at that document. | +| **uiFramework** | `never` | optional | [REMOVED] `RestApiPluginConfig.openApi.uiFramework` was removed in @objectstack/spec 18.1.0 (ADR-0049 enforce-or-remove) — nothing ever read it: no `swagger-ui`, `redoc`, `rapidoc` or `elements` viewer was ever rendered from it, and the platform serves no HTML API documentation viewer at all, only the machine-readable OpenAPI document. Delete the key. The OpenAPI document is served at `GET /api/v1/openapi.json` under the REST server's default `api.basePath` and `api.version` (the path follows both) while `api.enableOpenApi` is on; point any viewer or client generator you run yourself at that document. | | **includeInternal** | `boolean` | optional (default: `false`) | Include internal endpoints in documentation | | **generateSchemas** | `boolean` | optional (default: `true`) | Auto-generate schemas from Zod definitions | | **includeExamples** | `boolean` | optional (default: `true`) | Include request/response examples | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 1aa68c2a4e5..0de63b3cc5e 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1544 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1542 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 32 | 440 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 13 | 71 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 29 | 176 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 285 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1544** | 14 protocol modules | +| **Total** | **197** | **1542** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 440 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **32 pages, 438 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -77,7 +77,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`contract.zod.ts`](/docs/references/api/contract) | `ApiError`, `BaseResponse`, `BatchLoadingStrategy`, `BulkRequest`, `BulkResponse`, `CreateRequest`, `DataLoaderConfig`, `DeleteResponse`, `ExportRequest`, `IdRequest`, `ListRecordResponse`, `ModificationResult`, `QueryOptimizationConfig`, `RecordData`, `SingleRecordResponse`, `UpdateRequest` | | [`discovery.zod.ts`](/docs/references/api/discovery) | `ApiRoutes`, `AuthFamilies`, `CapabilityDescriptor`, `Discovery`, `DiscoveryEnvironment`, `EnvironmentType`, `RouteHealthEntry`, `RouteHealthReport`, `ServiceInfo`, `ServiceSelfInfo`, `ServiceStatus`, `WellKnownCapabilities` | | [`dispatcher.zod.ts`](/docs/references/api/dispatcher) | `DispatcherConfig`, `DispatcherErrorCode`, `DispatcherErrorResponse`, `DispatcherRoute` | -| [`documentation.zod.ts`](/docs/references/api/documentation) | `ApiChangelogEntry`, `ApiDocumentationConfig`, `ApiTestCollection`, `ApiTestRequest`, `ApiTestingUiConfig`, `ApiTestingUiType`, `CodeGenerationTemplate`, `GeneratedApiDocumentation`, `OpenApiSecurityScheme`, `OpenApiServer`, `OpenApiSpec` | +| [`documentation.zod.ts`](/docs/references/api/documentation) | `ApiChangelogEntry`, `ApiDocumentationConfig`, `ApiTestCollection`, `ApiTestRequest`, `CodeGenerationTemplate`, `GeneratedApiDocumentation`, `OpenApiSecurityScheme`, `OpenApiServer`, `OpenApiSpec` | | [`endpoint.zod.ts`](/docs/references/api/endpoint) | `ApiEndpoint`, `ApiMapping` | | [`error-code-ledger.zod.ts`](/docs/references/api/error-code-ledger) | `ErrorCode`, `ProvenanceWaiver`, `StandardSynonymWaiver` | | [`errors.zod.ts`](/docs/references/api/errors) | `EnhancedApiError`, `ErrorCategory`, `ErrorResponse`, `FieldError`, `FieldErrorCode`, `RetryStrategy`, `StandardErrorCode` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 7b162852b5c..80c986c933e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 448 | +| `api/` | 446 | diff --git a/packages/spec/REST_API_PLUGIN.md b/packages/spec/REST_API_PLUGIN.md index 41fba2c8000..c9219e07d31 100644 --- a/packages/spec/REST_API_PLUGIN.md +++ b/packages/spec/REST_API_PLUGIN.md @@ -162,11 +162,15 @@ Automatically generates OpenAPI documentation from route definitions and Zod sch **Features:** - OpenAPI 3.0.x and 3.1.0 support -- Multiple UI frameworks (Swagger UI, Redoc, RapiDoc, Elements) - Auto-generated schemas from Zod definitions - Request/response examples - Server URLs, contact info, license info +The platform serves no HTML documentation viewer: the API origin publishes the OpenAPI +document only (`GET /api/v1/openapi.json` under the REST server's default `api.basePath` +and `api.version`). The former viewer keys `uiPath` and `uiFramework` are retired and +refused at parse with that prescription. + **Example:** ```typescript const openApi: OpenApiGenerationConfig = { @@ -176,8 +180,6 @@ const openApi: OpenApiGenerationConfig = { description: 'Comprehensive API for ObjectStack', apiVersion: '1.0.0', outputPath: '/api/docs/openapi.json', - uiPath: '/api/docs', - uiFramework: 'swagger-ui', generateSchemas: true, includeExamples: true, servers: [ diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index d1ef98db199..0b319049b8b 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -71,11 +71,6 @@ "ApiTestRequest (type)", "ApiTestRequestParsed (type)", "ApiTestRequestSchema (const)", - "ApiTestingUiConfig (type)", - "ApiTestingUiConfigParsed (type)", - "ApiTestingUiConfigSchema (const)", - "ApiTestingUiType (const)", - "ApiTestingUiType (type)", "AppDefinitionResponse (type)", "AppDefinitionResponseParsed (type)", "AppDefinitionResponseSchema (const)", diff --git a/packages/spec/authorable-defaults/api.json b/packages/spec/authorable-defaults/api.json index 42d60883399..73937aefe52 100644 --- a/packages/spec/authorable-defaults/api.json +++ b/packages/spec/authorable-defaults/api.json @@ -15,14 +15,6 @@ "api/ApiTestRequest:headers = {}", "api/ApiTestRequest:queryParams = {}", "api/ApiTestRequest:variables = {}", - "api/ApiTestingUiConfig:defaultModelsExpandDepth = 1", - "api/ApiTestingUiConfig:displayRequestDuration = true", - "api/ApiTestingUiConfig:enableCors = true", - "api/ApiTestingUiConfig:enableFilter = true", - "api/ApiTestingUiConfig:enableTryItOut = true", - "api/ApiTestingUiConfig:path = \"/api-docs\"", - "api/ApiTestingUiConfig:syntaxHighlighting = true", - "api/ApiTestingUiConfig:theme = \"light\"", "api/AssembledInstalledPackage:enabled = true", "api/AssembledInstalledPackage:status = \"installed\"", "api/AuthFeaturesConfig:organization = false", @@ -118,8 +110,6 @@ "api/OpenApiGenerationConfig:includeInternal = false", "api/OpenApiGenerationConfig:outputPath = \"/api/docs/openapi.json\"", "api/OpenApiGenerationConfig:title = \"ObjectStack API\"", - "api/OpenApiGenerationConfig:uiFramework = \"swagger-ui\"", - "api/OpenApiGenerationConfig:uiPath = \"/api/docs\"", "api/OpenApiGenerationConfig:version = \"3.0.3\"", "api/OpenApiSpec:openapi = \"3.0.0\"", "api/OpenApiSpec:servers = []", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index d30150bb3ee..d6c2702ca76 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -107,7 +107,7 @@ "api/ApiDocumentationConfig:termsOfService", "api/ApiDocumentationConfig:testCollections", "api/ApiDocumentationConfig:title", - "api/ApiDocumentationConfig:ui", + "api/ApiDocumentationConfig:ui [RETIRED]", "api/ApiDocumentationConfig:version", "api/ApiEndpoint:_lock", "api/ApiEndpoint:_lockDocsUrl", @@ -173,18 +173,6 @@ "api/ApiTestRequest:queryParams", "api/ApiTestRequest:url", "api/ApiTestRequest:variables", - "api/ApiTestingUiConfig:customCssUrl", - "api/ApiTestingUiConfig:customJsUrl", - "api/ApiTestingUiConfig:defaultModelsExpandDepth", - "api/ApiTestingUiConfig:displayRequestDuration", - "api/ApiTestingUiConfig:enableCors", - "api/ApiTestingUiConfig:enableFilter", - "api/ApiTestingUiConfig:enableTryItOut", - "api/ApiTestingUiConfig:layout", - "api/ApiTestingUiConfig:path", - "api/ApiTestingUiConfig:syntaxHighlighting", - "api/ApiTestingUiConfig:theme", - "api/ApiTestingUiConfig:type", "api/AppDefinitionResponse:data", "api/AppDefinitionResponse:error", "api/AppDefinitionResponse:meta", @@ -1232,8 +1220,8 @@ "api/OpenApiGenerationConfig:securitySchemes", "api/OpenApiGenerationConfig:servers", "api/OpenApiGenerationConfig:title", - "api/OpenApiGenerationConfig:uiFramework", - "api/OpenApiGenerationConfig:uiPath", + "api/OpenApiGenerationConfig:uiFramework [RETIRED]", + "api/OpenApiGenerationConfig:uiPath [RETIRED]", "api/OpenApiGenerationConfig:version", "api/OpenApiSecurityScheme:bearerFormat", "api/OpenApiSecurityScheme:description", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index 93ff2f315e7..c59e0702c10 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -55,9 +55,6 @@ "ApiTestCollectionSchema": "api/ApiTestCollection", "ApiTestRequest": "api/ApiTestRequest", "ApiTestRequestSchema": "api/ApiTestRequest", - "ApiTestingUiConfig": "api/ApiTestingUiConfig", - "ApiTestingUiConfigSchema": "api/ApiTestingUiConfig", - "ApiTestingUiType": "api/ApiTestingUiType", "AppDefinitionResponse": "api/AppDefinitionResponse", "AppDefinitionResponseSchema": "api/AppDefinitionResponse", "ApproveAiPendingActionResponse": "api/ApproveAiPendingActionResponse", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 9816ec22dd5..b02214c6afe 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -67,10 +67,6 @@ "ApiTestRequest": "src/api/documentation.zod.ts#ApiTestRequest (type)", "ApiTestRequestParsed": "src/api/documentation.zod.ts#ApiTestRequestParsed (type)", "ApiTestRequestSchema": "src/api/documentation.zod.ts#ApiTestRequestSchema (const)", - "ApiTestingUiConfig": "src/api/documentation.zod.ts#ApiTestingUiConfig (type)", - "ApiTestingUiConfigParsed": "src/api/documentation.zod.ts#ApiTestingUiConfigParsed (type)", - "ApiTestingUiConfigSchema": "src/api/documentation.zod.ts#ApiTestingUiConfigSchema (const)", - "ApiTestingUiType": "src/api/documentation.zod.ts#ApiTestingUiType (type)", "AppDefinitionResponse": "src/api/metadata.zod.ts#AppDefinitionResponse (type)", "AppDefinitionResponseParsed": "src/api/metadata.zod.ts#AppDefinitionResponseParsed (type)", "AppDefinitionResponseSchema": "src/api/metadata.zod.ts#AppDefinitionResponseSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 8f8c67bfc40..d0a1bee0ef2 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -29,8 +29,6 @@ "api/ApiRoutes", "api/ApiTestCollection", "api/ApiTestRequest", - "api/ApiTestingUiConfig", - "api/ApiTestingUiType", "api/AppDefinitionResponse", "api/ApproveAiPendingActionResponse", "api/AssembledInstalledPackage", diff --git a/packages/spec/src/api/docs-viewer-keys-retirement.test.ts b/packages/spec/src/api/docs-viewer-keys-retirement.test.ts new file mode 100644 index 00000000000..a2e2e934788 --- /dev/null +++ b/packages/spec/src/api/docs-viewer-keys-retirement.test.ts @@ -0,0 +1,616 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The docs-viewer keys are RETIRED (#25159) — ADR-0049 enforce-or-remove; + * triage route A (retire with tombstones and an ADR-0087 entry, no staged + * window), which follows the maintainer's ruling to take the docs viewer page + * offline. + * + * Three keys declared an HTML documentation viewer and nothing read any of + * them: + * + * - `ApiDocumentationConfig.ui` — a viewer type (`swagger-ui`, `redoc`, + * `scalar`, …) at `ui.path`, typed by `ApiTestingUiConfigSchema` / + * `ApiTestingUiType`; + * - `RestApiPluginConfig.openApi.uiPath` — "URL path to serve documentation + * UI", default `/api/docs`; + * - `RestApiPluginConfig.openApi.uiFramework` — its viewer selector, default + * `swagger-ui` (not named on the card; the same viewer declaration one line + * below `uiPath`, retired with it). + * + * Since the viewer page `GET {basePath}/docs` was retired, the API origin + * serves no viewer at all — only the OpenAPI document at + * `GET {basePath}/openapi.json` — so none of the three could be honoured. + * + * Bookkeeping shapes, pinned below: + * 1. Three `retiredKey()` tombstones: both objects are non-strict + * `z.object()`s, so a bare deletion would strip the key in silence + * (ADR-0104). `uiPath` / `uiFramework` carried Zod defaults, but nothing + * parses the REST API plugin config into a stored or built artifact, so + * the old defaults are refused like any other value. + * 2. The value defs `api/ApiTestingUiConfig` and `api/ApiTestingUiType` leave + * whole — orphans once `ui` is gone (RETIRED_DEFS_BY_MAJOR[18]). + * 3. No D2 conversion: neither config is a stack collection member or a + * stored row. `RETIRED_KEYS_BY_MAJOR[18]` carries the three keys; the D3 + * entry `api-docs-viewer-keys-retired` carries the prescription to + * `os migrate meta` and the upgrade guide. + * + * On the assertion set (the #13823 precedent): a schema refusal raises a + * `ZodError` whose issues carry `code` and `path` but no ADR-0112 `status` — + * that envelope belongs to the API error surface. So these pins assert the + * strongest set this surface has: refusal, the issue `code`, the `path` naming + * the key, and the prescription text (where the wording is the contract, pin + * the wording). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + EXPORT_ENTRY_POINTS, + exportNamesOf, + holdersOf, +} from '../../scripts/lib/export-origins-testkit'; +import { listRepoWalkFiles } from '../../scripts/lib/repo-walk-testkit'; +import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { ApiDocumentationConfigSchema, type ApiDocumentationConfig } from './documentation.zod'; +import { + OpenApiGenerationConfigSchema, + RestApiPluginConfigSchema, + type OpenApiGenerationConfig, +} from './plugin-rest-api.zod'; + +/** The live mechanism every prescription names, so an author can act on it. */ +const NAMES_THE_DOCUMENT = + /Delete the key\. The OpenAPI document is served at `GET \/api\/v1\/openapi\.json` under the REST server's default `api\.basePath` and `api\.version` \(the path follows both\) while `api\.enableOpenApi` is on/s; + +const UI_PRESCRIPTION = + /`ApiDocumentationConfig\.ui` was removed in @objectstack\/spec 18\.1\.0 \(ADR-0049 enforce-or-remove\) — nothing ever read it.*serves no HTML API documentation viewer at all/s; +const UI_PATH_PRESCRIPTION = + /`RestApiPluginConfig\.openApi\.uiPath` was removed in @objectstack\/spec 18\.1\.0 \(ADR-0049 enforce-or-remove\) — nothing ever read it.*the `\/api\/docs` default included.*serves no HTML API documentation viewer at all/s; +const UI_FRAMEWORK_PRESCRIPTION = + /`RestApiPluginConfig\.openApi\.uiFramework` was removed in @objectstack\/spec 18\.1\.0 \(ADR-0049 enforce-or-remove\) — nothing ever read it.*serves no HTML API documentation viewer at all/s; + +/** Customer-facing text carries the ADR, never a tracker number (`check:doc-authoring`). */ +const expectNoTrackerNumber = (message: string) => expect(message).not.toMatch(/#\d{3,}/); + +describe('docs-viewer retirement — `ApiDocumentationConfig.ui`', () => { + // The old spellings: a full viewer block, the minimal one, and the empty block. + const FORMER_VALUES = [ + { type: 'swagger-ui', path: '/api-docs', theme: 'light', enableTryItOut: true }, + { type: 'scalar' }, + {}, + ]; + + for (const ui of FORMER_VALUES) { + it(`refuses \`ui: ${JSON.stringify(ui)}\` at path \`ui\`, with the prescription`, () => { + const r = ApiDocumentationConfigSchema.safeParse({ version: '1.0.0', ui }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path[0] === 'ui'); + expect(issue, 'the refusal must name `ui`').toBeDefined(); + // A `retiredKey()` tombstone raises `invalid_type` from its `z.never()`. + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual(['ui']); + expect(issue!.message).toMatch(UI_PRESCRIPTION); + expect(issue!.message).toMatch(NAMES_THE_DOCUMENT); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue!.message.startsWith('`ApiDocumentationConfig.ui` was removed')).toBe(true); + expectNoTrackerNumber(issue!.message); + // Only the retired key is diagnosed. + expect(r.error.issues.map((i) => i.path.join('.'))).toEqual(['ui']); + }); + } + + it('fails tsc at the authoring site: the input type of the key is `never`', () => { + const authored: ApiDocumentationConfig = { + version: '1.0.0', + // @ts-expect-error — `ui` is a retiredKey() tombstone: its input type is `never` (#25159). + ui: { type: 'swagger-ui' }, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => ApiDocumentationConfigSchema.parse(authored)).toThrow(UI_PRESCRIPTION); + }); + + it('CONTROL: a config without `ui` parses, keeps its live keys and grows no `ui` property', () => { + const parsed = ApiDocumentationConfigSchema.parse({ + version: '1.0.0', + title: 'Acme API', + generateOpenApi: false, + }); + expect(parsed.title).toBe('Acme API'); + expect(parsed.generateOpenApi).toBe(false); + expect(parsed.enabled, 'a live default still applies').toBe(true); + // The non-strict strip path: absence must stay absence. + expect(parsed).not.toHaveProperty('ui'); + }); +}); + +describe('docs-viewer retirement — `RestApiPluginConfig.openApi.uiPath` / `uiFramework`', () => { + // Every former value of each, the old defaults included. + const CASES = [ + ...['/api/docs', '/docs', ''].map((value) => ({ key: 'uiPath', value, prescription: UI_PATH_PRESCRIPTION })), + ...['swagger-ui', 'redoc', 'rapidoc', 'elements'].map((value) => ({ + key: 'uiFramework', + value, + prescription: UI_FRAMEWORK_PRESCRIPTION, + })), + ]; + + for (const { key, value, prescription } of CASES) { + it(`OpenApiGenerationConfigSchema refuses \`${key}: ${JSON.stringify(value)}\` at its path, with the prescription`, () => { + const r = OpenApiGenerationConfigSchema.safeParse({ title: 'Acme API', [key]: value }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path[0] === key); + expect(issue, `the refusal must name \`${key}\``).toBeDefined(); + expect(issue!.code).toBe('invalid_type'); + expect(issue!.path).toEqual([key]); + expect(issue!.message).toMatch(prescription); + expect(issue!.message).toMatch(NAMES_THE_DOCUMENT); + expect(issue!.message.startsWith(`\`RestApiPluginConfig.openApi.${key}\` was removed`)).toBe(true); + expectNoTrackerNumber(issue!.message); + expect(r.error.issues.map((i) => i.path.join('.'))).toEqual([key]); + }); + } + + it('the whole-config door refuses both THROUGH `openApi`, located at `openApi.`', () => { + const r = RestApiPluginConfigSchema.safeParse({ + routes: [], + openApi: { enabled: true, uiPath: '/api/docs', uiFramework: 'swagger-ui' }, + }); + expect(r.success).toBe(false); + if (r.success) return; + const byPath = new Map(r.error.issues.map((i) => [i.path.join('.'), i])); + expect([...byPath.keys()].sort()).toEqual(['openApi.uiFramework', 'openApi.uiPath']); + expect(byPath.get('openApi.uiPath')!.code).toBe('invalid_type'); + expect(byPath.get('openApi.uiPath')!.message).toMatch(UI_PATH_PRESCRIPTION); + expect(byPath.get('openApi.uiFramework')!.code).toBe('invalid_type'); + expect(byPath.get('openApi.uiFramework')!.message).toMatch(UI_FRAMEWORK_PRESCRIPTION); + }); + + it('fails tsc at the authoring site: the input type of both keys is `never`', () => { + const authored: OpenApiGenerationConfig = { + title: 'Acme API', + // @ts-expect-error — `uiPath` is a retiredKey() tombstone: its input type is `never` (#25159). + uiPath: '/api/docs', + }; + expect(() => OpenApiGenerationConfigSchema.parse(authored)).toThrow(UI_PATH_PRESCRIPTION); + const authoredFramework: OpenApiGenerationConfig = { + title: 'Acme API', + // @ts-expect-error — `uiFramework` is a retiredKey() tombstone: its input type is `never` (#25159). + uiFramework: 'redoc', + }; + expect(() => OpenApiGenerationConfigSchema.parse(authoredFramework)).toThrow(UI_FRAMEWORK_PRESCRIPTION); + }); + + it('CONTROL: the live keys keep their values and defaults; nothing re-defaults either retired key', () => { + const empty = OpenApiGenerationConfigSchema.parse({}); + // The two retired defaults are no longer materialised… + expect(empty).not.toHaveProperty('uiPath'); + expect(empty).not.toHaveProperty('uiFramework'); + // …while the live siblings' defaults still apply, byte-identically to before. + expect(empty.enabled).toBe(true); + expect(empty.version).toBe('3.0.3'); + expect(empty.title).toBe('ObjectStack API'); + expect(empty.apiVersion).toBe('1.0.0'); + expect(empty.outputPath).toBe('/api/docs/openapi.json'); + expect(empty.includeInternal).toBe(false); + const viaPlugin = RestApiPluginConfigSchema.parse({ routes: [], openApi: { title: 'Acme API' } }); + expect(viaPlugin.openApi?.title).toBe('Acme API'); + expect(viaPlugin.openApi).not.toHaveProperty('uiPath'); + expect(viaPlugin.openApi).not.toHaveProperty('uiFramework'); + }); +}); + +/** The four names the two retired defs exported (2 schema consts + their types; `ApiTestingUiType` is both). */ +const RETIRED_NAMES = [ + 'ApiTestingUiConfigSchema', + 'ApiTestingUiConfigParsed', + 'ApiTestingUiConfig', + 'ApiTestingUiType', +] as const; +const RETIRED_DEFS = ['api/ApiTestingUiConfig', 'api/ApiTestingUiType'] as const; + +describe('docs-viewer retirement — the orphan value defs leave whole', () => { + it('every retired name has ZERO holders on any public entry; the carriers survive', () => { + // Anti-vacuity: the baseline must cover the real surface. + for (const needed of ['.', './api']) { + expect(EXPORT_ENTRY_POINTS, `exports map must include ${needed}`).toContain(needed); + } + expect(exportNamesOf('./api').length, './api must export a non-trivial surface').toBeGreaterThan(50); + + for (const name of RETIRED_NAMES) { + expect(holdersOf(name), `${name} must have zero holders after its retirement`).toEqual([]); + } + + // SURVIVAL: the carrier defs and their neighbours stay — a narrowing, not a module sweep. + const apiNames = exportNamesOf('./api'); + for (const name of [ + 'ApiDocumentationConfigSchema', + 'ApiTestCollectionSchema', + 'OpenApiSpecSchema', + 'OpenApiGenerationConfigSchema', + 'RestApiPluginConfigSchema', + ]) { + expect(apiNames, `${name} must SURVIVE this retirement`).toContain(name); + } + }); + + it('the api barrel resolves without the retired schemas and keeps the survivors', async () => { + const api = await import('./index'); + expect(api).not.toHaveProperty('ApiTestingUiConfigSchema'); + expect(api).not.toHaveProperty('ApiTestingUiType'); + expect(api).toHaveProperty('ApiDocumentationConfigSchema'); + expect(api).toHaveProperty('OpenApiGenerationConfigSchema'); + }); +}); + +describe('docs-viewer retirement — ADR-0087 registration', () => { + it('declares the three keys and the two defs under major 18, with one family D3 entry and no D2 conversion', () => { + for (const key of [ + 'api/ApiDocumentationConfig:ui', + 'api/OpenApiGenerationConfig:uiPath', + 'api/OpenApiGenerationConfig:uiFramework', + ]) { + expect(RETIRED_KEYS_BY_MAJOR[18], `${key} must be declared`).toContain(key); + } + for (const def of RETIRED_DEFS) { + expect(RETIRED_DEFS_BY_MAJOR[18], `${def} must be declared`).toContain(def); + } + const step = MIGRATIONS_BY_MAJOR[18]!; + const entry = step.semantic.find((e) => e.id === 'api-docs-viewer-keys-retired'); + expect(entry, 'the family D3 entry must be registered in the step-18 chain').toBeDefined(); + expect(entry!.surface).toBe( + 'ApiDocumentationConfig.ui / RestApiPluginConfig.openApi.uiPath / RestApiPluginConfig.openApi.uiFramework', + ); + expect(entry!.replacement).toContain('`GET /api/v1/openapi.json`'); + expect(entry!.replacement).toContain('`api.enableOpenApi`'); + // The step's rationale names the family too (its STEP18_RATIONALE fragment). + expect(step.rationale).toContain('`api-docs-viewer-keys-retired`'); + // Plugin TS configuration has no stored or stack source for a conversion to + // rewrite — a conversion id naming these keys would be a strip with nothing to strip. + expect(step.conversionIds.filter((id) => /ui-path|ui-framework|testing-ui|docs-viewer/.test(id))).toEqual([]); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — the tombstones type the three keys `never` +// and the retired names no longer resolve, so every typed authoring site fails +// to compile. The residue is what `tsc` does not judge: JSON, YAML, MD/MDX +// code fences, untyped `.js`, and TS literals cast through `as any` / +// `as never`. This walk covers that residue across five roots, each already +// declared for `@objectstack/spec#test` in `scripts/cross-package-test-inputs.mjs` +// and mirrored in `turbo.json` — the same roots and per-extension rule the +// compliance-families and rest_api retirement pins walk. +// +// Two legs: +// 1. REFERENCE — a retired NAME in an import/export specifier, a type +// position, a method call, or the def's registry/manifest spelling +// (`"api/ApiTestingUiConfig"`). A prose mention is not a reference. +// 2. STRUCTURAL — `uiPath` is not a unique name (`packages/rest/src/ui-routes.ts` +// holds a local variable of that name for the unrelated `/ui/view` route) +// and `ui` is an ordinary word, so an offender is an object literal (or +// YAML mapping) whose OWN keys include `uiPath` or `uiFramework`, or that +// is the VALUE of a `ui` key and carries a viewer-only member or a viewer +// `type`. The schema declaration frame itself — the argument of a +// `z.object(` / `strictObject(` call — is not an authoring. +// +// The bound, stated: a block assembled by SPREAD or computed keys, and a YAML +// flow mapping on one line, are invisible to the structural leg; +// `docs/**`, `.claude/**`, `.github/**` and the repo-root files are outside +// the radius. +describe('tree-scoped absence: nothing inside the declared radius still authors the docs-viewer surface', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` only the non-code extensions are scanned AND declared. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4). + */ + const EXCLUDED = new Set([ + // This pin authors the retired surface to assert the schemas refuse it. + THIS_FILE, + // The upstream anchor: the PUBLISHED authorable surface the retirement is + // measured against, written only by `gen:authorable-surface-base` and never + // by hand. It records what 18.0.0 shipped, so it must still name the + // retired def's keys until the anchor next moves. + 'packages/spec/authorable-surface.base.json', + ]); + const EXCLUDED_PREFIXES = [ + // Registers the retirement by key / def (entries, the template, the generated registry). + 'packages/spec/src/migrations/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + // ── Leg 1: references to a retired name ────────────────────────────────── + const NAME = new RegExp('\\b(' + RETIRED_NAMES.join('|') + ')\\b'); + const REFERENCE = new RegExp( + '(?:' + + '[{,]\\s*(?:type\\s+)?' + NAME.source + '\\s*[,}]' + + '|' + + '(?:typeof\\s+|:\\s*|<)' + NAME.source + '\\b' + + '|' + + NAME.source + '\\.\\w+\\(' + + '|' + + '"(?:' + RETIRED_DEFS.join('|') + ')[":]' + + ')', + ); + + // ── Leg 2: structural authorings ───────────────────────────────────────── + const RETIRED_KEYS = ['uiPath', 'uiFramework']; + /** Members only `ApiTestingUiConfigSchema` declared — an ordinary `ui` block carries none. */ + const VIEWER_MEMBERS = ['enableTryItOut', 'defaultModelsExpandDepth', 'displayRequestDuration', 'syntaxHighlighting', 'customCssUrl', 'customJsUrl']; + /** The viewer `type` values the two retired enums offered (`custom` is too generic to judge). */ + const VIEWER_TYPES = new Set(['swagger-ui', 'redoc', 'rapidoc', 'stoplight', 'scalar', 'graphiql', 'postman', 'elements']); + /** Callees whose `{` argument is a schema declaration, not an authoring. */ + const SCHEMA_CTORS = new Set(['object', 'strictObject', 'looseObject', 'extend']); + + interface Frame { kind: string; keys: Set; values: Map; parentKey?: string; schemaArg?: boolean; callee?: string } + + const isOffender = (f: Frame): boolean => { + if (f.schemaArg) return false; + if (RETIRED_KEYS.some((k) => f.keys.has(k))) return true; + if (f.parentKey !== 'ui') return false; + if (VIEWER_MEMBERS.some((k) => f.keys.has(k))) return true; + const type = f.values.get('type'); + return type !== undefined && VIEWER_TYPES.has(type); + }; + + /** + * One pass over JS/TS/JSON text: a stack of bracket frames, each `{` frame + * collecting its OWN keys (an identifier or quoted string in key position, + * after `{` or `,`, followed by `:`), the string value of each key, and the + * key it is the VALUE of. Strings and comments are skipped; a single- or + * double-quoted string never spans a line. Returns the 1-based line of each + * closing brace whose frame offends. + */ + const lexOffenders = (text: string): number[] => { + const out: number[] = []; + const stack: Frame[] = []; + let lastSig = ''; + let lastIdent = ''; + let justKey: string | undefined; + let colonKey: string | undefined; + let line = 1; + let i = 0; + const n = text.length; + const sawToken = (token: string, j: number) => { + const top = stack[stack.length - 1]; + const isKey = text[j] === ':' && top?.kind === '{' && (lastSig === '{' || lastSig === ','); + if (isKey) top!.keys.add(token); + justKey = isKey ? token : undefined; + }; + while (i < n) { + const c = text[i]!; + if (c === '\n') { line += 1; i += 1; continue; } + if (c === '/' && text[i + 1] === '/') { while (i < n && text[i] !== '\n') i += 1; continue; } + if (c === '/' && text[i + 1] === '*') { + i += 2; + while (i < n && !(text[i] === '*' && text[i + 1] === '/')) { if (text[i] === '\n') line += 1; i += 1; } + i += 2; + continue; + } + if (c === '"' || c === "'" || c === '`') { + const start = i; + i += 1; + while (i < n && text[i] !== c) { + if (text[i] === '\\') i += 1; + else if (text[i] === '\n') { if (c !== '`') break; line += 1; } + i += 1; + } + const token = text.slice(start + 1, i); + i += 1; + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + const top = stack[stack.length - 1]; + if (lastSig === ':' && colonKey !== undefined && top?.kind === '{' && c !== '`') top.values.set(colonKey, token); + if (c === '`') justKey = undefined; + else sawToken(token, j); + lastSig = 'str'; + continue; + } + if (/[A-Za-z_$]/.test(c)) { + const start = i; + while (i < n && /[\w$]/.test(text[i]!)) i += 1; + const token = text.slice(start, i); + let j = i; + while (j < n && (text[j] === ' ' || text[j] === '\t')) j += 1; + sawToken(token, j); + lastSig = 'id'; + lastIdent = token; + continue; + } + if (c === ':') { + colonKey = justKey; + justKey = undefined; + lastSig = ':'; + i += 1; + continue; + } + if (c === '{') { + const top = stack[stack.length - 1]; + stack.push({ + kind: c, + keys: new Set(), + values: new Map(), + parentKey: lastSig === ':' ? colonKey : undefined, + schemaArg: lastSig === '(' && top?.kind === '(' && SCHEMA_CTORS.has(top.callee ?? ''), + }); + } else if (c === '(') { + stack.push({ kind: c, keys: new Set(), values: new Map(), callee: lastSig === 'id' ? lastIdent : undefined }); + } else if (c === '[') { + stack.push({ kind: c, keys: new Set(), values: new Map() }); + } else if (c === '}' || c === ']' || c === ')') { + const frame = stack.pop(); + if (frame?.kind === '{' && c === '}' && isOffender(frame)) out.push(line); + } + if (!/\s/.test(c)) { lastSig = c; justKey = undefined; } + i += 1; + } + return out; + }; + + /** + * YAML: any mapping line keyed `uiPath` / `uiFramework`, and a block mapping + * opened by `ui:` whose OWN keys (the lines at its first child's column) + * carry a viewer member or a viewer `type`. Returns 1-based lines. + */ + const yamlOffenders = (text: string): number[] => { + const rows = text.split('\n').map((raw, idx) => { + const m = /^(\s*)(-\s+)?([A-Za-z_][\w]*)\s*:\s*(.*)$/.exec(raw); + if (!m) return null; + const value = m[4]!.replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, ''); + return { idx, col: m[1]!.length + (m[2]?.length ?? 0), key: m[3]!, value, opensBlock: value === '' }; + }); + const out: number[] = []; + rows.forEach((row, at) => { + if (!row) return; + if (RETIRED_KEYS.includes(row.key)) { out.push(row.idx + 1); return; } + if (row.key !== 'ui' || !row.opensBlock) return; + const frame: Frame = { kind: '{', keys: new Set(), values: new Map(), parentKey: 'ui' }; + let childCol: number | undefined; + for (let k = at + 1; k < rows.length; k += 1) { + const r = rows[k]; + if (!r) continue; + if (r.col <= row.col) break; + childCol ??= r.col; + if (r.col === childCol) { frame.keys.add(r.key); frame.values.set(r.key, r.value); } + } + if (isOffender(frame)) out.push(row.idx + 1); + }); + return out; + }; + + /** MD/MDX: only fenced code is judged — prose mentions and reference tables are not authorings. */ + const markdownOffenders = (text: string): number[] => { + const out: number[] = []; + const fence = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + for (let m = fence.exec(text); m; m = fence.exec(text)) { + const lang = m[1]!.toLowerCase(); + const body = m[2]!; + const offset = text.slice(0, m.index).split('\n').length; + const hits = lang === 'yaml' || lang === 'yml' ? yamlOffenders(body) : lexOffenders(body); + for (const h of hits) out.push(offset + h); + } + return out; + }; + + const mentions = (text: string): boolean => + RETIRED_KEYS.some((k) => text.includes(k)) || /\bui\b/.test(text); + + const structuralOffendersIn = (ext: string, text: string): number[] => { + if (!mentions(text)) return []; + if (ext === '.yaml' || ext === '.yml') return yamlOffenders(text); + if (ext === '.md' || ext === '.mdx') return markdownOffenders(text); + return lexOffenders(text); + }; + + const vanished: string[] = []; + /** Tolerates ONLY a path's disappearance mid-walk; every other fault is re-raised. */ + const readIfPresent = (full: string, rel: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + vanished.push(rel); + return undefined; + } + }; + + it('the reference matcher recognises a reference and ignores a prose mention (anti-vacuity)', () => { + expect(REFERENCE.test("import { ApiTestingUiConfigSchema } from '@objectstack/spec/api';")).toBe(true); + expect(REFERENCE.test("import { type ApiTestingUiType, OpenApiSpecSchema } from '@objectstack/spec/api';")).toBe(true); + expect(REFERENCE.test('const ui: ApiTestingUiConfig = { type: "redoc" };')).toBe(true); + expect(REFERENCE.test('ApiTestingUiConfigSchema.parse(value)')).toBe(true); + expect(REFERENCE.test('let v: z.infer;')).toBe(true); + expect(REFERENCE.test('"api/ApiTestingUiConfig",')).toBe(true); + expect(REFERENCE.test('"api/ApiTestingUiConfig:path",')).toBe(true); + expect(REFERENCE.test('the `ApiTestingUiConfigSchema` export was removed')).toBe(false); + expect(REFERENCE.test('"api/ApiTestCollection",')).toBe(false); // the live near-namesake + expect(REFERENCE.test('import { ApiTestCollectionSchema } from "./documentation.zod";')).toBe(false); + }); + + it('the structural matcher finds an authoring and ignores every neighbouring shape (anti-vacuity)', () => { + // Offenders — the retired spelling, in each syntax the walk reads. + expect(structuralOffendersIn('.ts', "createPlugin({ openApi: { title: 'X', uiPath: '/api/docs' } } as never)")).toEqual([1]); + expect(structuralOffendersIn('.ts', "const openApi = {\n uiFramework: 'redoc',\n};")).toEqual([3]); + expect(structuralOffendersIn('.json', '{ "openApi": { "uiPath": "/docs" } }')).toEqual([1]); + expect(structuralOffendersIn('.ts', "const docs = { version: '1.0.0', ui: { type: 'swagger-ui' } };")).toEqual([1]); + expect(structuralOffendersIn('.json', '{ "version": "1.0.0", "ui": { "enableTryItOut": true } }')).toEqual([1]); + expect(structuralOffendersIn('.yaml', 'openApi:\n title: X\n uiPath: /api/docs\n')).toEqual([3]); + expect(structuralOffendersIn('.yaml', 'documentation:\n version: 1.0.0\n ui:\n type: scalar\n')).toEqual([3]); + expect(structuralOffendersIn('.md', 'Prose.\n\n```ts\nconst openApi = { uiFramework: "elements" };\n```\n')).toEqual([4]); + // Neighbours that must NOT match. + // The REST server's `/ui/view` route: a LOCAL VARIABLE named `uiPath`, never a key. + expect(structuralOffendersIn('.ts', 'const uiPath = `${basePath}/ui`;\nregister({ path: `${uiPath}/view/:object/:type` });')).toEqual([]); + // The schema declaration itself: the `{` is `z.object(`'s argument, not an authoring. + expect(structuralOffendersIn('.ts', "const S = z.object({ uiPath: retiredKey('gone'), uiFramework: retiredKey('gone') });")).toEqual([]); + // An ordinary `ui` block carries no viewer member and no viewer type. + expect(structuralOffendersIn('.ts', "({ ui: { type: 'grid', theme: 'dark', path: '/x' } })")).toEqual([]); + expect(structuralOffendersIn('.yaml', 'ui:\n type: kanban\n theme: light\n')).toEqual([]); + // A viewer type that is NOT the value of a `ui` key belongs to something else. + expect(structuralOffendersIn('.ts', "({ renderer: { type: 'scalar' } })")).toEqual([]); + // Prose, comments and quoted strings are not authorings. + expect(structuralOffendersIn('.md', 'A host once wrote `uiPath: /api/docs` here.')).toEqual([]); + expect(structuralOffendersIn('.ts', "// openApi: { uiPath: '/api/docs' }")).toEqual([]); + expect(structuralOffendersIn('.ts', 'const s = "{ uiPath: \'/api/docs\' }";')).toEqual([]); + }); + + it('a path that VANISHES mid-walk is not a finding, and every other read fault still is', () => { + const before = vanished.length; + const gone = path.join(REPO_ROOT, 'packages/spec/does-not-exist.bundled_probe.mjs'); + expect(fs.existsSync(gone)).toBe(false); + expect(readIfPresent(gone, 'probe/gone')).toBeUndefined(); + expect(vanished.slice(before)).toEqual(['probe/gone']); + expect(readIfPresent(fileURLToPath(import.meta.url), THIS_FILE)).toContain('tree-scoped absence'); + expect(() => readIfPresent(path.join(REPO_ROOT, 'packages/spec'), 'probe/dir')).toThrow(); + expect(vanished.length).toBe(before + 1); + }); + + it('no reference to a retired name and no authoring of a retired key survives inside the declared radius', () => { + const offenders: string[] = []; + let visited = 0; + let bearing = 0; + // What git does not ignore, never the raw filesystem (`scripts/lib/repo-walk-testkit.ts`). + const skipDir = (name: string) => SKIPPED_DIRS.has(name) || name.startsWith('.'); + for (const root of WALK_ROOTS) { + for (const full of listRepoWalkFiles(path.join(REPO_ROOT, root), { skipDir })) { + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + const name = path.basename(full); + const ext = path.extname(name); + if (!(rel.startsWith('examples/') ? EXAMPLES_EXT : SCANNED_EXT).has(ext)) continue; + if (name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(name)) continue; + visited += 1; + const text = readIfPresent(full, rel); + if (text === undefined) continue; + if (RETIRED_KEYS.some((k) => text.includes(k))) bearing += 1; + const m = REFERENCE.exec(text); + if (m) offenders.push(`${rel} references \`${m[0].trim()}\``); + for (const lineNo of structuralOffendersIn(ext, text)) offenders.push(`${rel}:${lineNo} authors a retired docs-viewer key`); + } + } + // Anti-vacuity: the walk covered the tree, and the files that CAN hold an + // authoring (they spell a retired key at all) were really judged. + expect(visited).toBeGreaterThan(1000); + expect(bearing).toBeGreaterThan(2); + expect(offenders, 'a surviving reference or authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/api/documentation.test.ts b/packages/spec/src/api/documentation.test.ts index d534d70a312..c8678e6d613 100644 --- a/packages/spec/src/api/documentation.test.ts +++ b/packages/spec/src/api/documentation.test.ts @@ -3,8 +3,6 @@ import { OpenApiServerSchema, OpenApiSecuritySchemeSchema, OpenApiSpecSchema, - ApiTestingUiType, - ApiTestingUiConfigSchema, ApiTestRequestSchema, ApiTestCollectionSchema, ApiChangelogEntrySchema, @@ -188,72 +186,6 @@ describe('API Documentation Protocol', () => { }); }); - describe('ApiTestingUiType', () => { - it('should accept valid UI types', () => { - expect(ApiTestingUiType.parse('swagger-ui')).toBe('swagger-ui'); - expect(ApiTestingUiType.parse('redoc')).toBe('redoc'); - expect(ApiTestingUiType.parse('rapidoc')).toBe('rapidoc'); - expect(ApiTestingUiType.parse('stoplight')).toBe('stoplight'); - expect(ApiTestingUiType.parse('scalar')).toBe('scalar'); - expect(ApiTestingUiType.parse('graphiql')).toBe('graphiql'); - expect(ApiTestingUiType.parse('postman')).toBe('postman'); - expect(ApiTestingUiType.parse('custom')).toBe('custom'); - }); - - it('should reject invalid UI types', () => { - expect(() => ApiTestingUiType.parse('invalid')).toThrow(); - }); - }); - - describe('ApiTestingUiConfigSchema', () => { - it('should validate Swagger UI config', () => { - const config = { - type: 'swagger-ui' as const, - path: '/api-docs', - theme: 'light' as const, - enableTryItOut: true, - enableFilter: true, - enableCors: true, - defaultModelsExpandDepth: 1, - layout: { - deepLinking: true, - displayOperationId: false, - defaultModelRendering: 'example' as const, - docExpansion: 'list' as const, - }, - }; - - const result = ApiTestingUiConfigSchema.parse(config); - expect(result.type).toBe('swagger-ui'); - expect(result.theme).toBe('light'); - expect(result.enableTryItOut).toBe(true); - }); - - it('should apply defaults', () => { - const config = { - type: 'swagger-ui' as const, - }; - - const result = ApiTestingUiConfigSchema.parse(config); - expect(result.path).toBe('/api-docs'); - expect(result.theme).toBe('light'); - expect(result.enableTryItOut).toBe(true); - expect(result.enableFilter).toBe(true); - }); - - it('should support custom CSS and JS', () => { - const config = { - type: 'swagger-ui' as const, - customCssUrl: 'https://example.com/custom.css', - customJsUrl: 'https://example.com/custom.js', - }; - - const result = ApiTestingUiConfigSchema.parse(config); - expect(result.customCssUrl).toBe('https://example.com/custom.css'); - expect(result.customJsUrl).toBe('https://example.com/custom.js'); - }); - }); - describe('ApiTestRequestSchema', () => { it('should validate complete test request', () => { const request = { @@ -467,11 +399,6 @@ describe('API Documentation Protocol', () => { description: 'Staging', }, ], - ui: { - type: 'swagger-ui' as const, - theme: 'light' as const, - enableTryItOut: true, - }, generateOpenApi: true, generateTestCollections: true, testCollections: [ diff --git a/packages/spec/src/api/documentation.zod.ts b/packages/spec/src/api/documentation.zod.ts index cc4a5eb5f63..62faf27969d 100644 --- a/packages/spec/src/api/documentation.zod.ts +++ b/packages/spec/src/api/documentation.zod.ts @@ -5,21 +5,24 @@ import { z } from 'zod'; /** * API Documentation & Testing Interface Protocol * - * Provides schemas for generating interactive API documentation and testing - * interfaces similar to Swagger UI, Postman, etc. - * + * Provides schemas for describing API documentation and test collections. + * * Features: * - OpenAPI/Swagger specification generation - * - Interactive API testing playground + * - API test requests and collections * - API versioning and changelog * - Code generation templates * - Mock server configuration - * + * * Architecture Alignment: - * - Swagger UI: Interactive API documentation + * - OpenAPI: the machine-readable API description * - Postman: API testing collections - * - Redoc: Documentation rendering - * + * + * The platform serves no HTML documentation viewer: the API origin publishes + * the OpenAPI document (`GET {basePath}/openapi.json`) and nothing else, so + * this module declares no viewer — the retired `ui` key is refused at parse + * with that prescription. + * * @example Documentation Config * ```typescript * const docConfig: ApiDocumentationConfig = { @@ -27,11 +30,6 @@ import { z } from 'zod'; * title: 'ObjectStack API', * version: '1.0.0', * servers: [{ url: 'https://api.example.com', description: 'Production' }], - * ui: { - * type: 'swagger-ui', - * theme: 'light', - * enableTryItOut: true - * } * } * ``` */ @@ -46,6 +44,7 @@ import { z } from 'zod'; * Server configuration for OpenAPI specification. */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const OpenApiServerSchema = lazySchema(() => z.object({ /** Server URL */ url: z.string().url().describe('Server base URL'), @@ -194,93 +193,48 @@ export type OpenApiSpecParsed = z.infer; // API Testing Playground // ========================================== -/** - * API Testing UI Type - */ -export const ApiTestingUiType = z.enum([ - 'swagger-ui', // Swagger UI - 'redoc', // Redoc - 'rapidoc', // RapiDoc - 'stoplight', // Stoplight Elements - 'scalar', // Scalar API Reference - 'graphiql', // GraphiQL - 'postman', // Postman-like interface - 'custom', // Custom implementation -]); - -export type ApiTestingUiType = z.input; - -/** - * API Testing UI Configuration Schema - * - * Configuration for interactive API testing interface. - * - * @example Swagger UI Config - * ```json - * { - * "type": "swagger-ui", - * "path": "/api-docs", - * "theme": "light", - * "enableTryItOut": true, - * "enableFilter": true, - * "enableCors": true, - * "defaultModelsExpandDepth": 1 - * } - * ``` - */ -export const ApiTestingUiConfigSchema = lazySchema(() => z.object({ - /** UI type */ - type: ApiTestingUiType.describe('Testing UI implementation'), - - /** UI path */ - path: z.string().default('/api-docs').describe('URL path for documentation UI'), - - /** UI theme */ - theme: z.enum(['light', 'dark', 'auto']).default('light').describe('UI color theme'), - - /** Enable try-it-out feature */ - enableTryItOut: z.boolean().default(true).describe('Enable interactive API testing'), - - /** Enable filtering */ - enableFilter: z.boolean().default(true).describe('Enable endpoint filtering'), - - /** Enable CORS for testing */ - enableCors: z.boolean().default(true).describe('Enable CORS for browser testing'), - - /** Default expand depth for models */ - defaultModelsExpandDepth: z.number().int().min(-1).default(1) - .describe('Default expand depth for schemas (-1 = fully expand)'), - - /** Display request duration */ - displayRequestDuration: z.boolean().default(true).describe('Show request duration'), - - /** Syntax highlighting */ - syntaxHighlighting: z.boolean().default(true).describe('Enable syntax highlighting'), - - /** Custom CSS URL */ - customCssUrl: z.string().url().optional().describe('Custom CSS stylesheet URL'), - - /** Custom JavaScript URL */ - customJsUrl: z.string().url().optional().describe('Custom JavaScript URL'), - - /** Layout options */ - layout: z.object({ - showExtensions: z.boolean().default(false).describe('Show vendor extensions'), - showCommonExtensions: z.boolean().default(false).describe('Show common extensions'), - deepLinking: z.boolean().default(true).describe('Enable deep linking'), - displayOperationId: z.boolean().default(false).describe('Display operation IDs'), - defaultModelRendering: z.enum(['example', 'model']).default('example') - .describe('Default model rendering mode'), - defaultModelsExpandDepth: z.number().int().default(1).describe('Models expand depth'), - defaultModelExpandDepth: z.number().int().default(1).describe('Single model expand depth'), - docExpansion: z.enum(['list', 'full', 'none']).default('list') - .describe('Documentation expansion mode'), - }).optional().describe('Layout configuration'), -})); - -export type ApiTestingUiConfig = z.input; -/** Post-parse shape of {@link ApiTestingUiConfig} — defaults applied, transforms run (ADR-0122). */ -export type ApiTestingUiConfigParsed = z.infer; +// ─── [#25159] `ApiDocumentationConfig.ui` and its viewer defs are RETIRED ─── +// +// ADR-0049 enforce-or-remove; triage route A (no staged window), which follows +// the maintainer's ruling to take the docs viewer page offline. `ui` declared +// an interactive documentation viewer — `ApiTestingUiType` (`swagger-ui`, +// `redoc`, `rapidoc`, `stoplight`, `scalar`, `graphiql`, `postman`, `custom`) +// at `ApiTestingUiConfigSchema.path` (default `/api-docs`), with a theme, +// try-it-out, CORS and layout switches — and NOTHING read it: measured at this +// retirement's base (b93c750c7), zero hits for `ApiTestingUi` outside +// `packages/spec` in this repo and in objectui at its pinned sha, against a +// lit control on the same instrument; nothing in the tree parses +// `ApiDocumentationConfigSchema` outside its own unit tests. Since the viewer +// page `GET {basePath}/docs` was retired, the API origin serves no HTML viewer +// at all, only the OpenAPI document at `GET {basePath}/openapi.json` (mounted +// by `packages/rest`'s `registerOpenApiEndpoints` while `api.enableOpenApi` is +// on), so the key could not be honoured either. +// +// Two bookkeeping shapes, one retirement: +// 1. `ApiDocumentationConfig.ui` — `retiredKey()` tombstone below (the shape +// is a non-strict `z.object`, so a bare deletion would be a silent strip, +// ADR-0104); `api/ApiDocumentationConfig:ui` in `RETIRED_KEYS_BY_MAJOR[18]`. +// 2. `ApiTestingUiConfigSchema` / `ApiTestingUiType` (and the +// `ApiTestingUiConfig` / `ApiTestingUiConfigParsed` / `ApiTestingUiType` +// types) — orphan value schemas once `ui` is gone: they had no other +// consumer, and an exported value schema with no consumer reads as a +// capability. `api/ApiTestingUiConfig` + `api/ApiTestingUiType` in +// `RETIRED_DEFS_BY_MAJOR[18]`. +// +// No D2 conversion, deliberately: an `ApiDocumentationConfig` is not a stack +// collection member and never a `sys_metadata` row, so a MetadataConversion +// would be a transform with no seam that ever runs (the `kernel/Manifest:loading` +// disposition). The D3 semantic entry `api-docs-viewer-keys-retired` carries +// the prescription outward, beside its REST-plugin siblings `openApi.uiPath` +// and `openApi.uiFramework` (`plugin-rest-api.zod.ts`). +const API_DOCUMENTATION_UI_RETIRED = + '`ApiDocumentationConfig.ui` was removed in @objectstack/spec 18.1.0 (ADR-0049 ' + + 'enforce-or-remove) — nothing ever read it: no viewer was ever mounted from it, and the ' + + 'platform serves no HTML API documentation viewer at all, only the machine-readable OpenAPI ' + + 'document. Delete the key. The OpenAPI document is served at `GET /api/v1/openapi.json` ' + + 'under the REST server\'s default `api.basePath` and `api.version` (the path follows both) ' + + 'while `api.enableOpenApi` is on; point any viewer or client generator you run yourself at ' + + 'that document.'; /** * API Test Request Schema @@ -445,7 +399,9 @@ export type CodeGenerationTemplate = z.input z.object({ servers: z.array(OpenApiServerSchema).optional().default([]) .describe('API server URLs'), - /** UI configuration */ - ui: ApiTestingUiConfigSchema.optional().describe('Testing UI configuration'), + /** + * RETIRED (#25159, ADR-0049): `ui` declared a documentation viewer nothing + * ever mounted, and the platform serves none — the retirement record sits + * above `API_DOCUMENTATION_UI_RETIRED`. Tombstoned rather than deleted: this + * shape is not `.strict()`, so authoring it must fail loudly (tsc `never` + + * the parse-time prescription), never strip in silence. The key carried no + * Zod `.default()`, and nothing parses this config into a stored or built + * artifact, so there is no emitted residue to tolerate. + */ + ui: retiredKey(API_DOCUMENTATION_UI_RETIRED), /** Generate OpenAPI spec */ generateOpenApi: z.boolean().default(true).describe('Generate OpenAPI 3.0 specification'), diff --git a/packages/spec/src/api/plugin-rest-api.test.ts b/packages/spec/src/api/plugin-rest-api.test.ts index df32975b7cc..3354a2ed147 100644 --- a/packages/spec/src/api/plugin-rest-api.test.ts +++ b/packages/spec/src/api/plugin-rest-api.test.ts @@ -317,8 +317,6 @@ describe('plugin-rest-api.zod', () => { expect(config.title).toBe('ObjectStack API'); expect(config.apiVersion).toBe('1.0.0'); expect(config.outputPath).toBe('/api/docs/openapi.json'); - expect(config.uiPath).toBe('/api/docs'); - expect(config.uiFramework).toBe('swagger-ui'); expect(config.includeInternal).toBe(false); expect(config.generateSchemas).toBe(true); expect(config.includeExamples).toBe(true); @@ -337,19 +335,6 @@ describe('plugin-rest-api.zod', () => { ).toThrow(); }); - it('should validate UI frameworks', () => { - const frameworks = ['swagger-ui', 'redoc', 'rapidoc', 'elements']; - - frameworks.forEach(framework => { - const config = OpenApiGenerationConfigSchema.parse({ uiFramework: framework }); - expect(config.uiFramework).toBe(framework); - }); - - expect(() => - OpenApiGenerationConfigSchema.parse({ uiFramework: 'invalid' }) - ).toThrow(); - }); - it('should validate complete config', () => { const config = OpenApiGenerationConfigSchema.parse({ enabled: true, @@ -358,8 +343,6 @@ describe('plugin-rest-api.zod', () => { description: 'API for my application', apiVersion: '2.0.0', outputPath: '/docs/api.json', - uiPath: '/docs', - uiFramework: 'redoc', includeInternal: true, generateSchemas: true, includeExamples: true, diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts index 57dc0768659..f365b815fca 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -538,9 +538,65 @@ export type ErrorHandlingConfigParsed = z.infer, z.infer< typeof M18.DispatcherErrorResponseSchema > >>; // api/documentation.zod.ts -export type Iso_api_documentation__ApiTestingUiType = Assert, z.infer< typeof M19.ApiTestingUiType > >>; export type Iso_api_documentation__CodeGenerationTemplateSchema = Assert, z.infer< typeof M19.CodeGenerationTemplateSchema > >>; export type Iso_api_documentation__OpenApiSecuritySchemeSchema = Assert, z.infer< typeof M19.OpenApiSecuritySchemeSchema > >>; export type Iso_api_documentation__OpenApiServerSchema = Assert, z.infer< typeof M19.OpenApiServerSchema > >>; @@ -1703,7 +1702,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 789 isomorphic pins', () => { + it('still declares all 788 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2552,7 +2551,11 @@ describe('ADR-0122 type-alias convention', () => { // and `draftChanges[].by` reports — a plain string enum, so it is pinned // here (Iso_api_protocol__MetadataAuthoringSurfaceSchema) rather than // given a `…Parsed` synonym. +1 added. - expect(pins).toHaveLength(789); + // + // 789 -> 788 is #25159: `ApiTestingUiType` left with the retired + // `ApiDocumentationConfig.ui` docs-viewer key, so its pin + // (Iso_api_documentation__ApiTestingUiType) left with it. -1. + expect(pins).toHaveLength(788); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index bb2c36edee3..f618c680963 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -28,6 +28,7 @@ "src/ai/agent-lifecycle-retirement.test.ts", "src/ai/agent-memory-store-retirement.test.ts", "src/ai/tool-confirmation-prescription-tense.pin.test.ts", + "src/api/docs-viewer-keys-retirement.test.ts", "src/api/error-catalog-docs.test.ts", "src/api/export-job-family-retirement.test.ts", "src/api/rest-api-config-dead-keys-retirement.test.ts",