Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
92 changes: 35 additions & 57 deletions src/openapi/generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,62 +895,6 @@ const generatePaths = async (
return paths
}

export const generateV30Spec = async (
req: Pick<PayloadRequest, 'payload' | 'protocol' | 'headers'>,
options: SanitizedPluginOptions,
): Promise<OpenAPIV3.Document> => {
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<string, OpenAPIV3.MediaTypeObject>,
}),
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<string, OpenAPIV3.MediaTypeObject>)
: {},
}
}, responses),
},
} satisfies OpenAPIV3.Document

adjustRefTargets(req.payload, liftedDefinitions, spec)

return spec
}

export const generateV31Spec = async (
req: Pick<PayloadRequest, 'payload' | 'protocol' | 'headers'>,
options: SanitizedPluginOptions,
Expand All @@ -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<PayloadRequest, 'payload' | 'protocol' | 'headers'>,
options: SanitizedPluginOptions,
): Promise<OpenAPIV3.Document> => {
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<string, JSONSchema4>,
),
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
}
2 changes: 2 additions & 0 deletions src/openapiPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const openapi =
enabled = true,
filters = {},
apiBasePath = null,
adjustGeneratedSpec,
}: PluginOptions): Plugin =>
({ endpoints = [], ...config }) => {
if (!enabled) {
Expand All @@ -30,6 +31,7 @@ const openapi =
authEndpoint,
filters,
apiBasePath,
adjustGeneratedSpec,
}),
},
{
Expand Down
27 changes: 26 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { OpenAPIV3_1 } from 'openapi-types'
import type { PayloadRequest } from 'payload'

export type OpenAPIVersion = '3.0' | '3.1'

Expand All @@ -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
Expand All @@ -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<PayloadRequest, 'payload' | 'protocol' | 'headers'>,
) => SpecAdjustment | Promise<SpecAdjustment>
}

export type SanitizedPluginOptions = Required<Omit<PluginOptions, 'enabled' | 'specEndpoint'>>
export type SanitizedPluginOptions = Required<
Omit<PluginOptions, 'enabled' | 'specEndpoint' | 'adjustGeneratedSpec'>
> &
Pick<PluginOptions, 'adjustGeneratedSpec'>

/**
* OpenAPI operation describing a Payload `endpoints` entry, supplied as `custom.openapi` on the
Expand Down
Loading
Loading