Skip to content

Commit add12a0

Browse files
committed
feat: Add custom endpoints
1 parent 469fbf4 commit add12a0

5 files changed

Lines changed: 300 additions & 2 deletions

File tree

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 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
type RequestBodyType = 'post' | 'patch'
106128

107129
const requestBodySchema = (fields: Array<Field>, schema: JSONSchema4): JSONSchema4 => ({
@@ -155,6 +177,48 @@ const generateRequestBodySchema = (
155177
}
156178
}
157179

180+
const getRequestBodySchema = (
181+
description: string,
182+
schema: OpenAPIV3_1.SchemaObject,
183+
): OpenAPIV3_1.RequestBodyObject => {
184+
return {
185+
description,
186+
content: {
187+
'application/json': {
188+
schema: {
189+
type: 'object',
190+
additionalProperties: false,
191+
...schema,
192+
},
193+
},
194+
},
195+
}
196+
}
197+
198+
const generateRequestBodyCustomEndpointSchemas = (collection: Collection) => {
199+
const requestBodies: Record<string, OpenAPIV3_1.RequestBodyObject> = {}
200+
const { singular } = collectionName(collection)
201+
202+
for (const endpoint of collection.config.endpoints || []) {
203+
if (endpoint.custom?.openapi) {
204+
const documentation: CustomEndpointDocumentation = endpoint.custom.openapi
205+
206+
if (documentation.requestBody) {
207+
const key = componentName('requestBodies', singular, {
208+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}`,
209+
})
210+
211+
requestBodies[key] = getRequestBodySchema(
212+
`Custom endpoint request body for ${singular} with method ${endpoint.method}`,
213+
documentation.requestBody,
214+
)
215+
}
216+
}
217+
}
218+
219+
return requestBodies
220+
}
221+
158222
const generateQueryOperationSchemas = (collection: Collection): Record<string, JSONSchema4> => {
159223
const { singular } = collectionName(collection)
160224

@@ -353,9 +417,42 @@ const generateCollectionResponses = (
353417
},
354418
},
355419
},
420+
...generateCollectionCustomEndpointResponses(collection),
356421
}
357422
}
358423

424+
const generateCollectionCustomEndpointResponses = (
425+
collection: Collection,
426+
): Record<string, OpenAPIV3_1.ResponseObject & OpenAPIV3.ResponseObject> => {
427+
const responses: Record<string, OpenAPIV3_1.ResponseObject & OpenAPIV3.ResponseObject> = {}
428+
const { singular } = collectionName(collection)
429+
430+
for (const endpoint of collection.config.endpoints || []) {
431+
if (endpoint.custom?.openapi) {
432+
const documentation: CustomEndpointDocumentation = endpoint.custom.openapi
433+
434+
for (const [statusCode] of Object.entries(documentation.responses || {})) {
435+
const key = componentName('responses', singular, {
436+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
437+
})
438+
439+
responses[key] = {
440+
description: `Custom endpoint response for ${singular} with method ${endpoint.method} and status code ${statusCode}`,
441+
content: {
442+
'application/json': {
443+
schema: composeRef('schemas', singular, {
444+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
445+
}),
446+
},
447+
},
448+
}
449+
}
450+
}
451+
}
452+
453+
return responses
454+
}
455+
359456
const isOpenToPublic = async (checker: Access): Promise<boolean> => {
360457
try {
361458
const result = await checker(
@@ -488,9 +585,48 @@ const generateCollectionOperations = async (
488585
security: (await isOpenToPublic(collection.config.access.delete)) ? [] : [apiKeySecurity],
489586
},
490587
},
588+
...(await generateCollectionCustomEndpointOperations(collection)),
491589
}
492590
}
493591

592+
const generateCollectionCustomEndpointOperations = async (
593+
collection: Collection,
594+
): Promise<Record<string, OpenAPIV3.PathItemObject & OpenAPIV3_1.PathItemObject>> => {
595+
const { slug } = collection.config
596+
const { singular, plural } = collectionName(collection)
597+
const tags = [plural]
598+
const operations: Record<string, OpenAPIV3.PathItemObject & OpenAPIV3_1.PathItemObject> = {}
599+
600+
for (const endpoint of collection.config.endpoints || []) {
601+
if (endpoint.custom?.openapi) {
602+
const path = endpoint.path.replace(/\/:([^/]+)/g, '/{$1}')
603+
const key = `/api/${slug}${path}`
604+
const { parameters, ...documentation }: CustomEndpointDocumentation = endpoint.custom.openapi
605+
606+
operations[key] = {
607+
...operations[key],
608+
parameters,
609+
[endpoint.method]: {
610+
...documentation,
611+
tags,
612+
requestBody: composeRef('requestBodies', singular, {
613+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}`,
614+
}),
615+
responses: Object.entries(documentation.responses || {}).reduce((acc, [statusCode]) => {
616+
acc[statusCode] = composeRef('responses', singular, {
617+
suffix: `CustomEndpoint${upperFirst(endpoint.method)}${statusCode}`,
618+
})
619+
620+
return acc
621+
}, {} as OpenAPIV3_1.ResponsesObject),
622+
},
623+
}
624+
}
625+
}
626+
627+
return operations
628+
}
629+
494630
const generateGlobalResponse = (
495631
global: SanitizedGlobalConfig,
496632
): OpenAPIV3_1.ResponseObject & OpenAPIV3.ResponseObject => {
@@ -587,6 +723,8 @@ const generateComponents = (req: Pick<PayloadRequest, 'payload'>) => {
587723
req.payload.config,
588724
collection,
589725
)
726+
727+
Object.assign(schemas, generateCustomEndpointSchemaObjects(collection))
590728
}
591729

592730
for (const collection of Object.values(req.payload.collections)) {
@@ -608,6 +746,8 @@ const generateComponents = (req: Pick<PayloadRequest, 'payload'>) => {
608746
)
609747
requestBodies[componentName('requestBodies', singular, { suffix: 'Patch' })] =
610748
generateRequestBodySchema(req.payload.config, collection, 'patch')
749+
750+
Object.assign(requestBodies, generateRequestBodyCustomEndpointSchemas(collection))
611751
}
612752

613753
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)