Skip to content

Commit e3ffca3

Browse files
committed
feat: Add custom endpoints
1 parent be434b0 commit e3ffca3

File tree

5 files changed

+300
-2
lines changed

5 files changed

+300
-2
lines changed

README.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Autogenerate an OpenAPI specification from your Payload CMS instance and use it
1313
- [x] Preferences endpoints
1414
- [x] Support Payload CMS 3.x
1515
- [x] Support generating both OpenAPI 3.0 and 3.1
16-
- [ ] Custom endpoints
16+
- [x] Custom endpoints
1717

1818
# Installation
1919

@@ -40,6 +40,52 @@ buildConfig({
4040
})
4141
```
4242

43+
To include custom endpoints you need to provide a documentation under the custom openapi property of the endpoint.
44+
45+
```typescript
46+
import type { CustomEndpointDocumentation } from 'payload-oapi'
47+
import type { CollectionConfig } from 'payload'
48+
49+
export const Pets: CollectionConfig = {
50+
slug: 'pets',
51+
// ...
52+
endpoints: [
53+
{
54+
// ...
55+
custom: {
56+
openapi: {
57+
summary: 'Delete pets by status',
58+
description: 'Delete pets by status',
59+
parameters: [
60+
{
61+
name: 'status',
62+
in: 'path',
63+
required: true,
64+
schema: {
65+
type: 'string',
66+
enum: ['available', 'pending', 'sold'],
67+
},
68+
},
69+
],
70+
responses: {
71+
200: {
72+
type: 'object',
73+
properties: {
74+
message: {
75+
type: 'string',
76+
description: 'A message indicating the result of the operation',
77+
},
78+
},
79+
},
80+
},
81+
} as CustomEndpointDocumentation<'3.1'>,
82+
}
83+
}
84+
]
85+
86+
}
87+
```
88+
4389
## 2. Add a documentation UI plugin (optional)
4490

4591
To provide a user interface for your API documentation, you can add one of the following plugins:

dev/collections/Pets.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { CustomEndpointDocumentation } from '@payload-oapi'
12
import type { CollectionConfig } from 'payload'
23

34
export const Categories: CollectionConfig = {
@@ -32,4 +33,86 @@ export const Pets: CollectionConfig = {
3233
},
3334
{ name: 'lastUpdateAt', type: 'date', timezone: true },
3435
],
36+
endpoints: [
37+
{
38+
handler: async () => {
39+
return Response.json({ message: 'Pet deleted successfully' })
40+
},
41+
method: 'delete',
42+
path: '/status/:status',
43+
custom: {
44+
openapi: {
45+
summary: 'Delete pets by status',
46+
description: 'Delete pets by status',
47+
parameters: [
48+
{
49+
name: 'status',
50+
in: 'path',
51+
required: true,
52+
schema: {
53+
type: 'string',
54+
enum: ['available', 'pending', 'sold'],
55+
},
56+
},
57+
],
58+
responses: {
59+
200: {
60+
type: 'object',
61+
properties: {
62+
message: {
63+
type: 'string',
64+
description: 'A message indicating the result of the operation',
65+
},
66+
},
67+
},
68+
},
69+
} as CustomEndpointDocumentation<'3.1'>,
70+
},
71+
},
72+
{
73+
handler: async () => {
74+
return Response.json({ message: 'Pet added successfully' })
75+
},
76+
method: 'post',
77+
path: '/status/:status',
78+
custom: {
79+
openapi: {
80+
summary: 'Add a new pet by status',
81+
description: 'Add a new pet by status',
82+
parameters: [
83+
{
84+
name: 'status',
85+
in: 'path',
86+
required: true,
87+
schema: {
88+
type: 'string',
89+
enum: ['available', 'pending', 'sold'],
90+
},
91+
},
92+
],
93+
requestBody: {
94+
type: 'object',
95+
properties: {
96+
name: {
97+
type: 'string',
98+
description: 'Name of the pet',
99+
},
100+
},
101+
required: ['name'],
102+
},
103+
responses: {
104+
200: {
105+
type: 'object',
106+
properties: {
107+
message: {
108+
type: 'string',
109+
description: 'A message indicating the result of the operation',
110+
},
111+
},
112+
},
113+
},
114+
} as CustomEndpointDocumentation<'3.1'>,
115+
},
116+
},
117+
],
35118
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,7 @@ import rapidoc from './rapidocPlugin.js'
33
import redoc from './redocPlugin.js'
44
import scalar from './scalarPlugin.js'
55
import swaggerUI from './swaggerUIPlugin.js'
6+
import type { CustomEndpointDocumentation } from './types.js'
67

78
export { openapi, swaggerUI, rapidoc, redoc, scalar }
9+
export type { CustomEndpointDocumentation }

src/openapi/generators.ts

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ import type {
1616
SelectField,
1717
} from 'payload'
1818
import { entityToJSONSchema } from 'payload'
19-
import type { SanitizedPluginOptions } from '../types.js'
19+
import type { CustomEndpointDocumentation, SanitizedPluginOptions } from '../types.js'
2020
import { mapValuesAsync, visitObjectNodes } from '../utils/objects.js'
21+
import { upperFirst } from '../utils/strings.js'
2122
import { type ComponentType, collectionName, componentName, globalName } from './naming.js'
2223
import { apiKeySecurity, generateSecuritySchemes } from './securitySchemes.js'
2324

@@ -102,6 +103,27 @@ const generateSchemaObject = (config: SanitizedConfig, collection: Collection):
102103
}
103104
}
104105

106+
const generateCustomEndpointSchemaObjects = (collection: Collection) => {
107+
const schemas: Record<string, JSONSchema4> = {}
108+
const { singular } = collectionName(collection)
109+
110+
for (const endpoint of collection.config.endpoints || []) {
111+
if (endpoint.custom?.openapi) {
112+
const { parameters, ...documentation }: CustomEndpointDocumentation = endpoint.custom.openapi
113+
114+
for (const [statusCode, response] of Object.entries(documentation.responses || {})) {
115+
const key = componentName('schemas', singular, {
116+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
117+
})
118+
119+
schemas[key] = response
120+
}
121+
}
122+
}
123+
124+
return schemas
125+
}
126+
105127
const requestBodySchema = (fields: Array<Field>, schema: JSONSchema4): JSONSchema4 => ({
106128
...schema,
107129
properties: Object.fromEntries(
@@ -140,6 +162,48 @@ const generateRequestBodySchema = (
140162
}
141163
}
142164

165+
const getRequestBodySchema = (
166+
description: string,
167+
schema: OpenAPIV3_1.SchemaObject,
168+
): OpenAPIV3_1.RequestBodyObject => {
169+
return {
170+
description,
171+
content: {
172+
'application/json': {
173+
schema: {
174+
type: 'object',
175+
additionalProperties: false,
176+
...schema,
177+
},
178+
},
179+
},
180+
}
181+
}
182+
183+
const generateRequestBodyCustomEndpointSchemas = (collection: Collection) => {
184+
const requestBodies: Record<string, OpenAPIV3_1.RequestBodyObject> = {}
185+
const { singular } = collectionName(collection)
186+
187+
for (const endpoint of collection.config.endpoints || []) {
188+
if (endpoint.custom?.openapi) {
189+
const { parameters, ...documentation }: CustomEndpointDocumentation = endpoint.custom.openapi
190+
191+
if (documentation.requestBody) {
192+
const key = componentName('requestBodies', singular, {
193+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}`,
194+
})
195+
196+
requestBodies[key] = getRequestBodySchema(
197+
`Custom endpoint request body for ${singular} with method ${endpoint.method}`,
198+
documentation.requestBody,
199+
)
200+
}
201+
}
202+
}
203+
204+
return requestBodies
205+
}
206+
143207
const generateQueryOperationSchemas = (collection: Collection): Record<string, JSONSchema4> => {
144208
const { singular } = collectionName(collection)
145209

@@ -338,9 +402,42 @@ const generateCollectionResponses = (
338402
},
339403
},
340404
},
405+
...generateCollectionCustomEndpointResponses(collection),
341406
}
342407
}
343408

409+
const generateCollectionCustomEndpointResponses = (
410+
collection: Collection,
411+
): Record<string, OpenAPIV3_1.ResponseObject & OpenAPIV3.ResponseObject> => {
412+
const responses: Record<string, OpenAPIV3_1.ResponseObject & OpenAPIV3.ResponseObject> = {}
413+
const { singular } = collectionName(collection)
414+
415+
for (const endpoint of collection.config.endpoints || []) {
416+
if (endpoint.custom?.openapi) {
417+
const documentation: CustomEndpointDocumentation = endpoint.custom.openapi
418+
419+
for (const [statusCode] of Object.entries(documentation.responses || {})) {
420+
const key = componentName('responses', singular, {
421+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
422+
})
423+
424+
responses[key] = {
425+
description: `Custom endpoint response for ${singular} with method ${endpoint.method} and status code ${statusCode}`,
426+
content: {
427+
'application/json': {
428+
schema: composeRef('schemas', singular, {
429+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
430+
}),
431+
},
432+
},
433+
}
434+
}
435+
}
436+
}
437+
438+
return responses
439+
}
440+
344441
const isOpenToPublic = async (checker: Access): Promise<boolean> => {
345442
try {
346443
const result = await checker(
@@ -464,9 +561,48 @@ const generateCollectionOperations = async (
464561
security: (await isOpenToPublic(collection.config.access.delete)) ? [] : [apiKeySecurity],
465562
},
466563
},
564+
...(await generateCollectionCustomEndpointOperations(collection)),
467565
}
468566
}
469567

568+
const generateCollectionCustomEndpointOperations = async (
569+
collection: Collection,
570+
): Promise<Record<string, OpenAPIV3.PathItemObject & OpenAPIV3_1.PathItemObject>> => {
571+
const { slug } = collection.config
572+
const { singular, plural } = collectionName(collection)
573+
const tags = [plural]
574+
const operations: Record<string, OpenAPIV3.PathItemObject & OpenAPIV3_1.PathItemObject> = {}
575+
576+
for (const endpoint of collection.config.endpoints || []) {
577+
if (endpoint.custom?.openapi) {
578+
const path = endpoint.path.replace(/\/:([^/]+)/g, '/{$1}')
579+
const key = `/api/${slug}${path}`
580+
const { parameters, ...documentation }: CustomEndpointDocumentation = endpoint.custom.openapi
581+
582+
operations[key] = {
583+
...operations[key],
584+
parameters,
585+
[endpoint.method]: {
586+
...documentation,
587+
tags,
588+
requestBody: composeRef('requestBodies', singular, {
589+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}`,
590+
}),
591+
responses: Object.entries(documentation.responses || {}).reduce((acc, [statusCode]) => {
592+
acc[statusCode] = composeRef('responses', singular, {
593+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
594+
})
595+
596+
return acc
597+
}, {} as OpenAPIV3_1.ResponsesObject),
598+
},
599+
}
600+
}
601+
}
602+
603+
return operations
604+
}
605+
470606
const generateGlobalResponse = (
471607
global: SanitizedGlobalConfig,
472608
): OpenAPIV3_1.ResponseObject & OpenAPIV3.ResponseObject => {
@@ -563,6 +699,8 @@ const generateComponents = (req: Pick<PayloadRequest, 'payload'>) => {
563699
req.payload.config,
564700
collection,
565701
)
702+
703+
Object.assign(schemas, generateCustomEndpointSchemaObjects(collection))
566704
}
567705

568706
for (const collection of Object.values(req.payload.collections)) {
@@ -581,6 +719,8 @@ const generateComponents = (req: Pick<PayloadRequest, 'payload'>) => {
581719
req.payload.config,
582720
collection,
583721
)
722+
723+
Object.assign(requestBodies, generateRequestBodyCustomEndpointSchemas(collection))
584724
}
585725

586726
for (const global of req.payload.globals.config) {

src/types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import type { JSONSchema4 } from 'json-schema'
2+
import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
3+
14
export type OpenAPIVersion = '3.0' | '3.1'
25

36
export interface OpenAPIMetadata {
@@ -15,3 +18,27 @@ export interface PluginOptions {
1518
}
1619

1720
export type SanitizedPluginOptions = Required<Omit<PluginOptions, 'enabled' | 'specEndpoint'>>
21+
22+
type ParameterObject<TVersion extends OpenAPIVersion = '3.1'> = TVersion extends '3.1'
23+
? OpenAPIV3_1.ParameterObject
24+
: OpenAPIV3.ParameterObject
25+
26+
type SchemaObject<TVersion extends OpenAPIVersion = '3.1'> = TVersion extends '3.1'
27+
? OpenAPIV3_1.SchemaObject
28+
: OpenAPIV3.SchemaObject
29+
30+
export interface CustomEndpointDocumentation<TVersion extends OpenAPIVersion = '3.1'> {
31+
description: string
32+
parameters?: ParameterObject<TVersion>[]
33+
queryParameters?: Record<
34+
string,
35+
{
36+
description?: string
37+
required?: boolean
38+
schema: SchemaObject<TVersion> | string
39+
}
40+
>
41+
requestBody?: SchemaObject<TVersion>
42+
responses?: Record<string, JSONSchema4>
43+
summary?: string
44+
}

0 commit comments

Comments
 (0)