From 08cc45aa6eca9d6a4b5a1cdb81c362772200ab83 Mon Sep 17 00:00:00 2001 From: Jan Buchar Date: Fri, 18 Sep 2026 15:10:04 +0200 Subject: [PATCH] feat: Add the adjustGeneratedSpec hook --- README.md | 36 ++++++ src/openapi/generators.ts | 92 ++++++--------- src/openapiPlugin.ts | 2 + src/types.ts | 27 ++++- .../openapi-generators.test.ts.snap | 38 ------- test/openapi-generators.test.ts | 106 +++++++++++++++++- 6 files changed, 203 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 6feb545..5b5a863 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,42 @@ Write these in OpenAPI 3.1 syntax whatever `openapiVersion` you configured — a out. `$ref` pointers to generated components work as shown; to add schemas of your own, declare them through Payload's `typescript.schema` and reference them the same way. +## 6. Adjust the finished document (optional) + +`adjustGeneratedSpec` gets the last word on the spec. Use it for anything the plugin does not model — +paths Payload does not serve, extra components, or removing a generated operation that you'd like to exclude from the spec: + +```typescript +openapi({ + metadata: { title: 'Dev API', version: '0.0.1' }, + adjustGeneratedSpec: (spec, req) => { + spec.paths['/external/health'] = { + get: { summary: 'Health check', responses: { 200: { description: 'ok' } } }, + } + + delete spec.paths['/api/posts'].post + }, +}) +``` + +- it always receives a **3.1** document, even with `openapiVersion: '3.0'` — write 3.1 syntax and it + is down-converted afterwards, exactly like `custom.openapi` +- mutate the argument or return a replacement; it may be `async` +- it runs per request and gets `req`, so the spec can depend on the locale or the current user +- `$ref` targets are already resolved, so generated components can be reused by pointer + +Component names derive from `labels.singular`, so they are not a stable contract — a collection you +declare yourself gets `Post`, while the same slug can yield `Posts` elsewhere. Copy a `$ref` the +generator produced instead of assembling one: + +```typescript +adjustGeneratedSpec: spec => { + const posts = spec.paths['/api/posts'].get.responses['200'] // { $ref: '…/PostListResponse' } + + spec.paths['/external/latest'] = { get: { responses: { 200: posts } } } +} +``` + # Auth endpoints Collections with `auth` get their login, logout, refresh, verification and password-reset operations documented diff --git a/src/openapi/generators.ts b/src/openapi/generators.ts index 474c60a..cca930a 100644 --- a/src/openapi/generators.ts +++ b/src/openapi/generators.ts @@ -895,62 +895,6 @@ const generatePaths = async ( return paths } -export const generateV30Spec = async ( - req: Pick, - options: SanitizedPluginOptions, -): Promise => { - const { schemas, requestBodies, responses, liftedDefinitions } = generateComponents(req, options) - - const apiRoute = options.apiBasePath ?? req.payload.config.routes.api - - const spec = { - openapi: '3.0.3', - info: options.metadata, - servers: [{ url: `${req.protocol}//${req.headers.get('host')}` }], - paths: (await convertInlineSchemas(await generatePaths(req, options))) as OpenAPIV3.PathsObject, - components: { - securitySchemes: generateSecuritySchemes(options.authEndpoint, apiRoute), - schemas: await mapValuesAsync(jsonSchemaToOpenapiSchema, schemas), - requestBodies: await mapValuesAsync( - async requestBody => ({ - ...requestBody, - content: (await mapValuesAsync( - async contentItem => ({ - ...contentItem, - schema: contentItem.schema - ? await jsonSchemaToOpenapiSchema(contentItem.schema as JSONSchema4) - : undefined, - }), - requestBody.content, - )) as Record, - }), - requestBodies, - ), - responses: await mapValuesAsync(async response => { - return { - ...response, - content: - response.content !== undefined - ? ((await mapValuesAsync( - async contentItem => ({ - ...contentItem, - schema: contentItem.schema - ? await jsonSchemaToOpenapiSchema(contentItem.schema as JSONSchema4) - : undefined, - }), - response.content, - )) as Record) - : {}, - } - }, responses), - }, - } satisfies OpenAPIV3.Document - - adjustRefTargets(req.payload, liftedDefinitions, spec) - - return spec -} - export const generateV31Spec = async ( req: Pick, options: SanitizedPluginOptions, @@ -974,5 +918,39 @@ export const generateV31Spec = async ( adjustRefTargets(req.payload, liftedDefinitions, spec) - return spec + // Adjusted last, so the hook sees resolved refs and can be the final word on the document. + return (await options.adjustGeneratedSpec?.(spec, req)) ?? spec +} + +/** + * A 3.0 document is the 3.1 one down-converted, so everything reaching the spec - generated + * schemas, `custom.openapi` operations and whatever `adjustGeneratedSpec` added - is written in one + * dialect and converted in one place. + */ +export const generateV30Spec = async ( + req: Pick, + options: SanitizedPluginOptions, +): Promise => { + const spec = await generateV31Spec(req, options) + + return { + ...spec, + openapi: '3.0.3', + paths: (await convertInlineSchemas(spec.paths)) as OpenAPIV3.PathsObject, + components: { + ...spec.components, + schemas: await mapValuesAsync( + jsonSchemaToOpenapiSchema, + (spec.components?.schemas ?? {}) as Record, + ), + requestBodies: (await convertInlineSchemas(spec.components?.requestBodies)) as Record< + string, + OpenAPIV3.RequestBodyObject + >, + responses: (await convertInlineSchemas(spec.components?.responses)) as Record< + string, + OpenAPIV3.ResponseObject + >, + }, + } as OpenAPIV3.Document } diff --git a/src/openapiPlugin.ts b/src/openapiPlugin.ts index 1aaf7ab..fb4aeb0 100644 --- a/src/openapiPlugin.ts +++ b/src/openapiPlugin.ts @@ -11,6 +11,7 @@ const openapi = enabled = true, filters = {}, apiBasePath = null, + adjustGeneratedSpec, }: PluginOptions): Plugin => ({ endpoints = [], ...config }) => { if (!enabled) { @@ -30,6 +31,7 @@ const openapi = authEndpoint, filters, apiBasePath, + adjustGeneratedSpec, }), }, { diff --git a/src/types.ts b/src/types.ts index f61b25c..b345517 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { OpenAPIV3_1 } from 'openapi-types' +import type { PayloadRequest } from 'payload' export type OpenAPIVersion = '3.0' | '3.1' @@ -16,6 +17,10 @@ export interface FilterOptions { excludeGlobals?: string[] } +/** Returning nothing keeps the mutated argument; `() => void` cannot be typed as `() => undefined`. */ +// biome-ignore lint/suspicious/noConfusingVoidType: see above +type SpecAdjustment = OpenAPIV3_1.Document | void + export interface PluginOptions { enabled?: boolean openapiVersion?: OpenAPIVersion @@ -25,9 +30,29 @@ export interface PluginOptions { filters?: FilterOptions /** Path prefix for generated operations, defaults to the Payload `routes.api` setting. */ apiBasePath?: string | null + /** + * Last word on the generated document. Receives a 3.1 document even when `openapiVersion` is + * `'3.0'` - write 3.1 syntax and it is down-converted afterwards, like `custom.openapi`. Mutate + * the argument or return a replacement. Runs per request, after `$ref` targets are resolved. + * + * Component names derive from `labels.singular`, so they are not a stable contract - reuse a + * `$ref` the generator emitted rather than assembling one from a slug: + * + * ```typescript + * const posts = spec.paths['/api/posts'].get.responses['200'] // { $ref: '…/PostListResponse' } + * spec.paths['/external/latest'] = { get: { responses: { 200: posts } } } + * ``` + */ + adjustGeneratedSpec?: ( + spec: OpenAPIV3_1.Document, + req: Pick, + ) => SpecAdjustment | Promise } -export type SanitizedPluginOptions = Required> +export type SanitizedPluginOptions = Required< + Omit +> & + Pick /** * OpenAPI operation describing a Payload `endpoints` entry, supplied as `custom.openapi` on the diff --git a/test/__snapshots__/openapi-generators.test.ts.snap b/test/__snapshots__/openapi-generators.test.ts.snap index 046c621..4df89a9 100644 --- a/test/__snapshots__/openapi-generators.test.ts.snap +++ b/test/__snapshots__/openapi-generators.test.ts.snap @@ -661,7 +661,6 @@ exports[`openapi generators > converts empty config correctly 1`] = ` "description": "List of Payload Locked Documents", }, "PayloadLockedDocumentNotFoundResponse": { - "content": {}, "description": "Payload Locked Document not found", }, "PayloadLockedDocumentResponse": { @@ -734,7 +733,6 @@ exports[`openapi generators > converts empty config correctly 1`] = ` "description": "List of Payload Migrations", }, "PayloadMigrationNotFoundResponse": { - "content": {}, "description": "Payload Migration not found", }, "PayloadMigrationResponse": { @@ -807,7 +805,6 @@ exports[`openapi generators > converts empty config correctly 1`] = ` "description": "List of Payload Preferences", }, "PayloadPreferenceNotFoundResponse": { - "content": {}, "description": "Payload Preference not found", }, "PayloadPreferenceResponse": { @@ -900,7 +897,6 @@ exports[`openapi generators > converts empty config correctly 1`] = ` "description": "The currently authenticated users, if any", }, "UsersNotFoundResponse": { - "content": {}, "description": "users not found", }, "UsersRefreshTokenResponse": { @@ -4169,7 +4165,6 @@ exports[`openapi generators > handles block editor fields correctly 1`] = ` "description": "List of Pages", }, "PageNotFoundResponse": { - "content": {}, "description": "Page not found", }, "PageResponse": { @@ -4242,7 +4237,6 @@ exports[`openapi generators > handles block editor fields correctly 1`] = ` "description": "List of Payload Locked Documents", }, "PayloadLockedDocumentNotFoundResponse": { - "content": {}, "description": "Payload Locked Document not found", }, "PayloadLockedDocumentResponse": { @@ -4315,7 +4309,6 @@ exports[`openapi generators > handles block editor fields correctly 1`] = ` "description": "List of Payload Migrations", }, "PayloadMigrationNotFoundResponse": { - "content": {}, "description": "Payload Migration not found", }, "PayloadMigrationResponse": { @@ -4388,7 +4381,6 @@ exports[`openapi generators > handles block editor fields correctly 1`] = ` "description": "List of Payload Preferences", }, "PayloadPreferenceNotFoundResponse": { - "content": {}, "description": "Payload Preference not found", }, "PayloadPreferenceResponse": { @@ -4481,7 +4473,6 @@ exports[`openapi generators > handles block editor fields correctly 1`] = ` "description": "The currently authenticated users, if any", }, "UsersNotFoundResponse": { - "content": {}, "description": "users not found", }, "UsersRefreshTokenResponse": { @@ -8065,7 +8056,6 @@ exports[`openapi generators > handles blocks referenced from the config 1`] = ` "description": "List of Pages", }, "PageNotFoundResponse": { - "content": {}, "description": "Page not found", }, "PageResponse": { @@ -8138,7 +8128,6 @@ exports[`openapi generators > handles blocks referenced from the config 1`] = ` "description": "List of Payload Locked Documents", }, "PayloadLockedDocumentNotFoundResponse": { - "content": {}, "description": "Payload Locked Document not found", }, "PayloadLockedDocumentResponse": { @@ -8211,7 +8200,6 @@ exports[`openapi generators > handles blocks referenced from the config 1`] = ` "description": "List of Payload Migrations", }, "PayloadMigrationNotFoundResponse": { - "content": {}, "description": "Payload Migration not found", }, "PayloadMigrationResponse": { @@ -8284,7 +8272,6 @@ exports[`openapi generators > handles blocks referenced from the config 1`] = ` "description": "List of Payload Preferences", }, "PayloadPreferenceNotFoundResponse": { - "content": {}, "description": "Payload Preference not found", }, "PayloadPreferenceResponse": { @@ -8377,7 +8364,6 @@ exports[`openapi generators > handles blocks referenced from the config 1`] = ` "description": "The currently authenticated users, if any", }, "UsersNotFoundResponse": { - "content": {}, "description": "users not found", }, "UsersRefreshTokenResponse": { @@ -11651,7 +11637,6 @@ exports[`openapi generators > handles datetime field with timezones correctly 1` "description": "List of Events", }, "EventNotFoundResponse": { - "content": {}, "description": "Event not found", }, "EventResponse": { @@ -11959,7 +11944,6 @@ exports[`openapi generators > handles datetime field with timezones correctly 1` "description": "List of Payload Locked Documents", }, "PayloadLockedDocumentNotFoundResponse": { - "content": {}, "description": "Payload Locked Document not found", }, "PayloadLockedDocumentResponse": { @@ -12032,7 +12016,6 @@ exports[`openapi generators > handles datetime field with timezones correctly 1` "description": "List of Payload Migrations", }, "PayloadMigrationNotFoundResponse": { - "content": {}, "description": "Payload Migration not found", }, "PayloadMigrationResponse": { @@ -12105,7 +12088,6 @@ exports[`openapi generators > handles datetime field with timezones correctly 1` "description": "List of Payload Preferences", }, "PayloadPreferenceNotFoundResponse": { - "content": {}, "description": "Payload Preference not found", }, "PayloadPreferenceResponse": { @@ -12198,7 +12180,6 @@ exports[`openapi generators > handles datetime field with timezones correctly 1` "description": "The currently authenticated users, if any", }, "UsersNotFoundResponse": { - "content": {}, "description": "users not found", }, "UsersRefreshTokenResponse": { @@ -15776,7 +15757,6 @@ exports[`openapi generators > handles interfaceName correctly 1`] = ` "description": "List of Payload Locked Documents", }, "PayloadLockedDocumentNotFoundResponse": { - "content": {}, "description": "Payload Locked Document not found", }, "PayloadLockedDocumentResponse": { @@ -15849,7 +15829,6 @@ exports[`openapi generators > handles interfaceName correctly 1`] = ` "description": "List of Payload Migrations", }, "PayloadMigrationNotFoundResponse": { - "content": {}, "description": "Payload Migration not found", }, "PayloadMigrationResponse": { @@ -15922,7 +15901,6 @@ exports[`openapi generators > handles interfaceName correctly 1`] = ` "description": "List of Payload Preferences", }, "PayloadPreferenceNotFoundResponse": { - "content": {}, "description": "Payload Preference not found", }, "PayloadPreferenceResponse": { @@ -16015,7 +15993,6 @@ exports[`openapi generators > handles interfaceName correctly 1`] = ` "description": "The currently authenticated User, if any", }, "UserNotFoundResponse": { - "content": {}, "description": "User not found", }, "UserRefreshTokenResponse": { @@ -19103,7 +19080,6 @@ exports[`openapi generators > handles non-default api route 1`] = ` "description": "List of Payload Locked Documents", }, "PayloadLockedDocumentNotFoundResponse": { - "content": {}, "description": "Payload Locked Document not found", }, "PayloadLockedDocumentResponse": { @@ -19176,7 +19152,6 @@ exports[`openapi generators > handles non-default api route 1`] = ` "description": "List of Payload Migrations", }, "PayloadMigrationNotFoundResponse": { - "content": {}, "description": "Payload Migration not found", }, "PayloadMigrationResponse": { @@ -19249,7 +19224,6 @@ exports[`openapi generators > handles non-default api route 1`] = ` "description": "List of Payload Preferences", }, "PayloadPreferenceNotFoundResponse": { - "content": {}, "description": "Payload Preference not found", }, "PayloadPreferenceResponse": { @@ -19322,7 +19296,6 @@ exports[`openapi generators > handles non-default api route 1`] = ` "description": "List of Posts", }, "PostNotFoundResponse": { - "content": {}, "description": "Post not found", }, "PostResponse": { @@ -19415,7 +19388,6 @@ exports[`openapi generators > handles non-default api route 1`] = ` "description": "The currently authenticated users, if any", }, "UsersNotFoundResponse": { - "content": {}, "description": "users not found", }, "UsersRefreshTokenResponse": { @@ -22910,7 +22882,6 @@ exports[`openapi generators > handles non-default collection 1`] = ` "description": "List of Payload Locked Documents", }, "PayloadLockedDocumentNotFoundResponse": { - "content": {}, "description": "Payload Locked Document not found", }, "PayloadLockedDocumentResponse": { @@ -22983,7 +22954,6 @@ exports[`openapi generators > handles non-default collection 1`] = ` "description": "List of Payload Migrations", }, "PayloadMigrationNotFoundResponse": { - "content": {}, "description": "Payload Migration not found", }, "PayloadMigrationResponse": { @@ -23056,7 +23026,6 @@ exports[`openapi generators > handles non-default collection 1`] = ` "description": "List of Payload Preferences", }, "PayloadPreferenceNotFoundResponse": { - "content": {}, "description": "Payload Preference not found", }, "PayloadPreferenceResponse": { @@ -23129,7 +23098,6 @@ exports[`openapi generators > handles non-default collection 1`] = ` "description": "List of Posts", }, "PostNotFoundResponse": { - "content": {}, "description": "Post not found", }, "PostResponse": { @@ -23222,7 +23190,6 @@ exports[`openapi generators > handles non-default collection 1`] = ` "description": "The currently authenticated users, if any", }, "UsersNotFoundResponse": { - "content": {}, "description": "users not found", }, "UsersRefreshTokenResponse": { @@ -26717,7 +26684,6 @@ exports[`openapi generators > respects default ID type from db adapter 1`] = ` "description": "List of Payload Locked Documents", }, "PayloadLockedDocumentNotFoundResponse": { - "content": {}, "description": "Payload Locked Document not found", }, "PayloadLockedDocumentResponse": { @@ -26790,7 +26756,6 @@ exports[`openapi generators > respects default ID type from db adapter 1`] = ` "description": "List of Payload Migrations", }, "PayloadMigrationNotFoundResponse": { - "content": {}, "description": "Payload Migration not found", }, "PayloadMigrationResponse": { @@ -26863,7 +26828,6 @@ exports[`openapi generators > respects default ID type from db adapter 1`] = ` "description": "List of Payload Preferences", }, "PayloadPreferenceNotFoundResponse": { - "content": {}, "description": "Payload Preference not found", }, "PayloadPreferenceResponse": { @@ -26936,7 +26900,6 @@ exports[`openapi generators > respects default ID type from db adapter 1`] = ` "description": "List of Posts", }, "PostNotFoundResponse": { - "content": {}, "description": "Post not found", }, "PostResponse": { @@ -27029,7 +26992,6 @@ exports[`openapi generators > respects default ID type from db adapter 1`] = ` "description": "The currently authenticated users, if any", }, "UsersNotFoundResponse": { - "content": {}, "description": "users not found", }, "UsersRefreshTokenResponse": { diff --git a/test/openapi-generators.test.ts b/test/openapi-generators.test.ts index 4f72b56..b52426a 100644 --- a/test/openapi-generators.test.ts +++ b/test/openapi-generators.test.ts @@ -3,7 +3,7 @@ import { sqliteAdapter } from '@payloadcms/db-sqlite' import { lexicalEditor } from '@payloadcms/richtext-lexical' import { MongoMemoryServer } from 'mongodb-memory-server' import mongoose from 'mongoose' -import type { OpenAPIV3 } from 'openapi-types' +import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types' import { BasePayload, buildConfig, @@ -14,7 +14,7 @@ import { } from 'payload' import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest' import { generateV30Spec, generateV31Spec } from '../src/openapi/generators' -import type { CustomEndpointDocumentation } from '../src/types' +import type { CustomEndpointDocumentation, PluginOptions } from '../src/types' const Posts: CollectionConfig = { slug: 'posts', @@ -688,6 +688,108 @@ describe('openapi generators', () => { }) }) + describe('adjustGeneratedSpec', () => { + const specs = async (adjustGeneratedSpec: PluginOptions['adjustGeneratedSpec']) => { + const payload = await buildPayload({ collections: [Posts] }) + const req = { protocol: 'https', headers: new Headers({ host: 'localhost' }), payload } + const options = { + authEndpoint: '/auth', + metadata: { title: 'Test API', version: '1.0' }, + filters: { hideInternalCollections: true }, + apiBasePath: null, + adjustGeneratedSpec, + } + + return { + v30: await generateV30Spec(req, { ...options, openapiVersion: '3.0' }), + v31: await generateV31Spec(req, { ...options, openapiVersion: '3.1' }), + } + } + + test('mutating the argument is enough', async () => { + const { v30 } = await specs(spec => { + spec.paths!['/external/health'] = { get: { responses: { 200: { description: 'ok' } } } } + }) + + expect(v30.paths['/external/health']?.get?.responses['200']).toEqual({ description: 'ok' }) + }) + + test('returning a replacement also works, and may be async', async () => { + const { v30 } = await specs(async spec => { + await Promise.resolve() + + return { ...spec, info: { ...spec.info, title: 'Replaced' } } + }) + + expect(v30.info.title).toBe('Replaced') + }) + + test('receives a 3.1 document even when generating 3.0', async () => { + const seen: string[] = [] + const { v30, v31 } = await specs(spec => { + seen.push(spec.openapi) + }) + + expect(seen).toEqual(['3.1.0', '3.1.0']) + expect(v30.openapi).toBe('3.0.3') + expect(v31.openapi).toBe('3.1.0') + }) + + test('injected 3.1 schemas are down-converted for 3.0', async () => { + const inject = (spec: OpenAPIV3_1.Document) => { + spec.paths!['/external/thing'] = { + get: { + responses: { + 200: { + description: 'ok', + content: { + 'application/json': { + schema: { type: 'object', properties: { note: { type: ['string', 'null'] } } }, + }, + }, + }, + }, + }, + } + } + + const { v30, v31 } = await specs(inject) + + const noteOf = (spec: OpenAPIV3.Document) => + ( + (spec.paths['/external/thing']?.get?.responses['200'] as OpenAPIV3.ResponseObject) + .content?.['application/json'].schema as OpenAPIV3.NonArraySchemaObject + ).properties?.note + + expect(noteOf(v30)).toEqual({ type: 'string', nullable: true }) + expect(noteOf(v31 as unknown as OpenAPIV3.Document)).toEqual({ type: ['string', 'null'] }) + }) + + test('sees resolved refs, so generated components can be reused', async () => { + const { v30 } = await specs(spec => { + const listed = spec.paths!['/api/posts']?.get?.responses?.['200'] + + spec.paths!['/external/latest'] = { + get: { responses: { 200: listed as OpenAPIV3_1.ReferenceObject } }, + } + }) + + expect(v30.paths['/external/latest']?.get?.responses['200']).toEqual({ + $ref: '#/components/responses/PostListResponse', + }) + expect(v30.components?.responses?.PostListResponse).toBeDefined() + }) + + test('can remove a generated operation', async () => { + const { v30 } = await specs(spec => { + delete spec.paths!['/api/posts']?.post + }) + + expect(v30.paths['/api/posts']?.post).toBeUndefined() + expect(v30.paths['/api/posts']?.get).toBeDefined() + }) + }) + describe('custom endpoints', () => { const noop = () => new Response(null)