Skip to content
Open
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
48 changes: 47 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Autogenerate an OpenAPI specification from your Payload CMS instance and use it
- [x] Preferences endpoints
- [x] Support Payload CMS 3.x
- [x] Support generating both OpenAPI 3.0 and 3.1
- [ ] Custom endpoints
- [x] Custom endpoints

# Installation

Expand All @@ -40,6 +40,52 @@ buildConfig({
})
```

To include custom endpoints you need to provide a documentation under the custom openapi property of the endpoint.

```typescript
import type { CustomEndpointDocumentation } from 'payload-oapi'
import type { CollectionConfig } from 'payload'

export const Pets: CollectionConfig = {
slug: 'pets',
// ...
endpoints: [
{
// ...
custom: {
openapi: {
summary: 'Delete pets by status',
description: 'Delete pets by status',
parameters: [
{
name: 'status',
in: 'path',
required: true,
schema: {
type: 'string',
enum: ['available', 'pending', 'sold'],
},
},
],
responses: {
200: {
type: 'object',
properties: {
message: {
type: 'string',
description: 'A message indicating the result of the operation',
},
},
},
},
} as CustomEndpointDocumentation<'3.1'>,
}
}
]

}
```

## 2. Add a documentation UI plugin (optional)

To provide a user interface for your API documentation, you can add one of the following plugins:
Expand Down
83 changes: 83 additions & 0 deletions dev/collections/Pets.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createCustomEndpointDocumentation } from '@payload-oapi'
import type { CollectionConfig } from 'payload'

export const Categories: CollectionConfig = {
Expand Down Expand Up @@ -32,4 +33,86 @@ export const Pets: CollectionConfig = {
},
{ name: 'lastUpdateAt', type: 'date', timezone: true },
],
endpoints: [
{
handler: async () => {
return Response.json({ message: 'Pet deleted successfully' })
},
method: 'delete',
path: '/status/:status',
custom: {
openapi: createCustomEndpointDocumentation({
summary: 'Delete pets by status',
description: 'Delete pets by status',
parameters: [
{
name: 'status',
in: 'path',
required: true,
schema: {
type: 'string',
enum: ['available', 'pending', 'sold'],
},
},
],
responses: {
200: {
type: 'object',
properties: {
message: {
type: 'string',
description: 'A message indicating the result of the operation',
},
},
},
},
}),
},
},
{
handler: async () => {
return Response.json({ message: 'Pet added successfully' })
},
method: 'post',
path: '/status/:status',
custom: {
openapi: createCustomEndpointDocumentation({
summary: 'Add a new pet by status',
description: 'Add a new pet by status',
parameters: [
{
name: 'status',
in: 'path',
required: true,
schema: {
type: 'string',
enum: ['available', 'pending', 'sold'],
},
},
],
requestBody: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name of the pet',
},
},
required: ['name'],
},
responses: {
200: {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great if the user could reference the various generated OpenAPI schemas here. Will a simple $ref work here? Can you add a test that verifies that please?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if I understand you correctly. The generated OpenAPI schemas aren't yet available here, as this definition will be used to generate them.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you know the correct component name, you can write a ref inside your custom endpoint definition - $ref: "#/components/schemas/UserResponse" or something like that. My question is if this will work as expected

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification 👍 That should be possible. I'll add a test for it

Copy link
Copy Markdown

@scastlara scastlara Feb 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what is worth I tested this branch on my project, and in order for this to work (using $ref to existing definitions), I needed to change this in generateCollectionCustomEndpointResponses:

const generateCollectionCustomEndpointResponses = (
  collection: Collection,
): Record<string, OpenAPIV3_1.ResponseObject & OpenAPIV3.ResponseObject> => {
      ...
      for (const [statusCode, resp] of Object.entries(documentation.responses || {})) {
        ...
        responses[key] = {
          description: `Custom endpoint response for ${singular} with method ${endpoint.method} and status code ${statusCode}`,
          content: {
            'application/json': {
              schema: resp.$ref
                ? composeRef('schemas', resp.$ref) // added this
                : composeRef('schemas', singular, {
                    suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
                  }),
            },
          },
        }
      }
    }
  }

  return responses
}

Otherwise, I could not get a ref working at all.

Now, I may be doing something dumb; I don't really know well how this package (or this PR) works.

With that change, I could reference the User object by doing this in the custom openapi section:

 responses: {
            200: {
              "$ref": "User"
            },
          },

UserResponse does not work.

type: 'object',
properties: {
message: {
type: 'string',
description: 'A message indicating the result of the operation',
},
},
},
},
}),
},
},
],
}
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@ import rapidoc from './rapidocPlugin.js'
import redoc from './redocPlugin.js'
import scalar from './scalarPlugin.js'
import swaggerUI from './swaggerUIPlugin.js'
import type { CustomEndpointDocumentation } from './types.js'
import { createCustomEndpointDocumentation } from './utils/customEndpoints.js'

export { openapi, swaggerUI, rapidoc, redoc, scalar }
export { openapi, swaggerUI, rapidoc, redoc, scalar, createCustomEndpointDocumentation }
export type { CustomEndpointDocumentation }
142 changes: 141 additions & 1 deletion src/openapi/generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ import type {
SelectField,
} from 'payload'
import { entityToJSONSchema } from 'payload'
import type { SanitizedPluginOptions } from '../types.js'
import type { CustomEndpointDocumentation, SanitizedPluginOptions } from '../types.js'
import { mapValuesAsync, visitObjectNodes } from '../utils/objects.js'
import { upperFirst } from '../utils/strings.js'
import { type ComponentType, collectionName, componentName, globalName } from './naming.js'
import { apiKeySecurity, generateSecuritySchemes } from './securitySchemes.js'

Expand Down Expand Up @@ -102,6 +103,27 @@ const generateSchemaObject = (config: SanitizedConfig, collection: Collection):
}
}

const generateCustomEndpointSchemaObjects = (collection: Collection) => {
const schemas: Record<string, JSONSchema4> = {}
const { singular } = collectionName(collection)

for (const endpoint of collection.config.endpoints || []) {
if (endpoint.custom?.openapi) {
const documentation: CustomEndpointDocumentation = endpoint.custom.openapi

for (const [statusCode, response] of Object.entries(documentation.responses || {})) {
const key = componentName('schemas', singular, {
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
})

schemas[key] = response
}
}
}

return schemas
}

type RequestBodyType = 'post' | 'patch'

const requestBodySchema = (fields: Array<Field>, schema: JSONSchema4): JSONSchema4 => ({
Expand Down Expand Up @@ -155,6 +177,48 @@ const generateRequestBodySchema = (
}
}

const getRequestBodySchema = (
description: string,
schema: OpenAPIV3_1.SchemaObject,
): OpenAPIV3_1.RequestBodyObject => {
return {
description,
content: {
'application/json': {
schema: {
type: 'object',
additionalProperties: false,
...schema,
},
},
},
}
}

const generateRequestBodyCustomEndpointSchemas = (collection: Collection) => {
const requestBodies: Record<string, OpenAPIV3_1.RequestBodyObject> = {}
const { singular } = collectionName(collection)

for (const endpoint of collection.config.endpoints || []) {
if (endpoint.custom?.openapi) {
const documentation: CustomEndpointDocumentation = endpoint.custom.openapi

if (documentation.requestBody) {
const key = componentName('requestBodies', singular, {
suffix: `CustomEndpoint${upperFirst(endpoint.method)}`,
})

requestBodies[key] = getRequestBodySchema(
`Custom endpoint request body for ${singular} with method ${endpoint.method}`,
documentation.requestBody,
)
}
}
}

return requestBodies
}

const generateQueryOperationSchemas = (collection: Collection): Record<string, JSONSchema4> => {
const { singular } = collectionName(collection)

Expand Down Expand Up @@ -353,9 +417,42 @@ const generateCollectionResponses = (
},
},
},
...generateCollectionCustomEndpointResponses(collection),
}
}

const generateCollectionCustomEndpointResponses = (
collection: Collection,
): Record<string, OpenAPIV3_1.ResponseObject & OpenAPIV3.ResponseObject> => {
const responses: Record<string, OpenAPIV3_1.ResponseObject & OpenAPIV3.ResponseObject> = {}
const { singular } = collectionName(collection)

for (const endpoint of collection.config.endpoints || []) {
if (endpoint.custom?.openapi) {
const documentation: CustomEndpointDocumentation = endpoint.custom.openapi

for (const [statusCode] of Object.entries(documentation.responses || {})) {
const key = componentName('responses', singular, {
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
})

responses[key] = {
description: `Custom endpoint response for ${singular} with method ${endpoint.method} and status code ${statusCode}`,
content: {
'application/json': {
schema: composeRef('schemas', singular, {
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
}),
},
},
}
}
}
}

return responses
}

const isOpenToPublic = async (checker: Access): Promise<boolean> => {
try {
const result = await checker(
Expand Down Expand Up @@ -488,9 +585,48 @@ const generateCollectionOperations = async (
security: (await isOpenToPublic(collection.config.access.delete)) ? [] : [apiKeySecurity],
},
},
...(await generateCollectionCustomEndpointOperations(collection)),
}
}

const generateCollectionCustomEndpointOperations = async (
collection: Collection,
): Promise<Record<string, OpenAPIV3.PathItemObject & OpenAPIV3_1.PathItemObject>> => {
const { slug } = collection.config
const { singular, plural } = collectionName(collection)
const tags = [plural]
const operations: Record<string, OpenAPIV3.PathItemObject & OpenAPIV3_1.PathItemObject> = {}

for (const endpoint of collection.config.endpoints || []) {
if (endpoint.custom?.openapi) {
const path = endpoint.path.replace(/\/:([^/]+)/g, '/{$1}')
const key = `/api/${slug}${path}`
const { parameters, ...documentation }: CustomEndpointDocumentation = endpoint.custom.openapi

operations[key] = {
...operations[key],
parameters,
[endpoint.method]: {
...documentation,
tags,
requestBody: composeRef('requestBodies', singular, {
suffix: `CustomEndpoint${upperFirst(endpoint.method)}`,
}),
responses: Object.entries(documentation.responses || {}).reduce((acc, [statusCode]) => {
acc[statusCode] = composeRef('responses', singular, {
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
})

return acc
}, {} as OpenAPIV3_1.ResponsesObject),
},
}
}
}

return operations
}

const generateGlobalResponse = (
global: SanitizedGlobalConfig,
): OpenAPIV3_1.ResponseObject & OpenAPIV3.ResponseObject => {
Expand Down Expand Up @@ -587,6 +723,8 @@ const generateComponents = (req: Pick<PayloadRequest, 'payload'>) => {
req.payload.config,
collection,
)

Object.assign(schemas, generateCustomEndpointSchemaObjects(collection))
}

for (const collection of Object.values(req.payload.collections)) {
Expand All @@ -608,6 +746,8 @@ const generateComponents = (req: Pick<PayloadRequest, 'payload'>) => {
)
requestBodies[componentName('requestBodies', singular, { suffix: 'Patch' })] =
generateRequestBodySchema(req.payload.config, collection, 'patch')

Object.assign(requestBodies, generateRequestBodyCustomEndpointSchemas(collection))
}

for (const global of req.payload.globals.config) {
Expand Down
27 changes: 27 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { JSONSchema4 } from 'json-schema'
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'

export type OpenAPIVersion = '3.0' | '3.1'

export interface OpenAPIMetadata {
Expand All @@ -15,3 +18,27 @@ export interface PluginOptions {
}

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

type ParameterObject<TVersion extends OpenAPIVersion = '3.0'> = TVersion extends '3.1'
? OpenAPIV3_1.ParameterObject
: OpenAPIV3.ParameterObject

type SchemaObject<TVersion extends OpenAPIVersion = '3.0'> = TVersion extends '3.1'
? OpenAPIV3_1.SchemaObject
: OpenAPIV3.SchemaObject

export interface CustomEndpointDocumentation<TVersion extends OpenAPIVersion = '3.0'> {
description: string
parameters?: ParameterObject<TVersion>[]
queryParameters?: Record<
string,
{
description?: string
required?: boolean
schema: SchemaObject<TVersion> | string
}
>
requestBody?: SchemaObject<TVersion>
responses?: Record<string, JSONSchema4>
summary?: string
}
Loading