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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +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
- [x] Collection and global filtering
- [ ] Custom endpoints

# Installation
Expand Down Expand Up @@ -68,6 +69,28 @@ buildConfig({
})
```

## 3. Filter collections and globals (optional)

Control which collections and globals appear in the OpenAPI spec using the `filters` option:

- `includeCollections` / `excludeCollections` — filter collections by slug
- `includeGlobals` / `excludeGlobals` — filter globals by slug
- `hideInternalCollections` — exclude `payload-*` collections

Example:

```typescript
openapi({
openapiVersion: '3.0',
metadata: { title: 'Dev API', version: '0.0.1' },
filters: {
includeCollections: ['posts', 'categories'],
excludeGlobals: ['footer'],
hideInternalCollections: true,
},
})
```

# Usage

Unless you configured it otherwise, your spec will be accessible via <https://your-payload.com/api/openapi.json>. If you
Expand Down
56 changes: 38 additions & 18 deletions src/openapi/generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
import { entityToJSONSchema } from 'payload'
import type { SanitizedPluginOptions } from '../types.js'
import { isHiddenField } from '../utils/fields.js'
import { shouldIncludeCollection, shouldIncludeGlobal } from '../utils/filters.js'
import { mapValuesAsync, visitObjectNodes } from '../utils/objects.js'
import { type ComponentType, collectionName, componentName, globalName } from './naming.js'
import { apiKeySecurity, generateSecuritySchemes } from './securitySchemes.js'
Expand Down Expand Up @@ -585,33 +586,44 @@ const generateGlobalOperations = async (
}
}

const generateComponents = (req: Pick<PayloadRequest, 'payload'>) => {
const generateComponents = (
req: Pick<PayloadRequest, 'payload'>,
options: SanitizedPluginOptions,
) => {
const schemas: Record<string, JSONSchema4> = {
supportedTimezones: {
type: 'string',
example: 'Europe/Prague',
},
}

for (const collection of Object.values(req.payload.collections)) {
const collections = Object.values(req.payload.collections).filter(collection =>
shouldIncludeCollection(collection, options.filters),
)

const globals = req.payload.globals.config.filter(global =>
shouldIncludeGlobal(global, options.filters),
)

for (const collection of collections) {
const { singular } = collectionName(collection)
schemas[componentName('schemas', singular)] = generateSchemaObject(
req.payload.config,
collection,
)
}

for (const collection of Object.values(req.payload.collections)) {
for (const collection of collections) {
Object.assign(schemas, generateQueryOperationSchemas(collection))
}

for (const global of req.payload.globals.config) {
for (const global of globals) {
Object.assign(schemas, generateGlobalSchemas(req.payload.config, global))
}

const requestBodies: Record<string, OpenAPIV3_1.RequestBodyObject> = {}

for (const collection of Object.values(req.payload.collections)) {
for (const collection of collections) {
const { singular } = collectionName(collection)
requestBodies[componentName('requestBodies', singular)] = generateRequestBodySchema(
req.payload.config,
Expand All @@ -622,15 +634,15 @@ const generateComponents = (req: Pick<PayloadRequest, 'payload'>) => {
generateRequestBodySchema(req.payload.config, collection, 'patch')
}

for (const global of req.payload.globals.config) {
for (const global of globals) {
requestBodies[componentName('requestBodies', globalName(global))] =
generateGlobalRequestBody(global)
}

const responses: Record<string, OpenAPIV3_1.ResponseObject> = Object.assign(
{},
...Object.values(req.payload.collections).map(generateCollectionResponses),
...req.payload.globals.config.map(global => ({
...collections.map(generateCollectionResponses),
...globals.map(global => ({
[componentName('responses', globalName(global))]: generateGlobalResponse(global),
})),
)
Expand All @@ -642,18 +654,22 @@ export const generateV30Spec = async (
req: Pick<PayloadRequest, 'payload' | 'protocol' | 'headers'>,
options: SanitizedPluginOptions,
): Promise<OpenAPIV3.Document> => {
const { schemas, requestBodies, responses } = generateComponents(req)
const { schemas, requestBodies, responses } = generateComponents(req, options)

const filters = options.filters ?? {}
const collections = Object.values(req.payload.collections).filter(collection =>
shouldIncludeCollection(collection, filters),
)
const globals = req.payload.globals.config.filter(global => shouldIncludeGlobal(global, filters))

const spec = {
openapi: '3.0.3',
info: options.metadata,
servers: [{ url: `${req.protocol}//${req.headers.get('host')}` }],
paths: Object.assign(
{},
...(await Promise.all(
Object.values(req.payload.collections).map(generateCollectionOperations),
)),
...(await Promise.all(req.payload.globals.config.map(generateGlobalOperations))),
...(await Promise.all(collections.map(generateCollectionOperations))),
...(await Promise.all(globals.map(generateGlobalOperations))),
),
components: {
securitySchemes: generateSecuritySchemes(options.authEndpoint),
Expand Down Expand Up @@ -702,18 +718,22 @@ export const generateV31Spec = async (
req: Pick<PayloadRequest, 'payload' | 'protocol' | 'headers'>,
options: SanitizedPluginOptions,
): Promise<OpenAPIV3_1.Document> => {
const { schemas, requestBodies, responses } = generateComponents(req)
const { schemas, requestBodies, responses } = generateComponents(req, options)

const filters = options.filters ?? {}
const collections = Object.values(req.payload.collections).filter(collection =>
shouldIncludeCollection(collection, filters),
)
const globals = req.payload.globals.config.filter(global => shouldIncludeGlobal(global, filters))

const spec = {
openapi: '3.1.0',
info: options.metadata,
servers: [{ url: `${req.protocol}//${req.headers.get('host')}` }],
paths: Object.assign(
{},
...(await Promise.all(
Object.values(req.payload.collections).map(generateCollectionOperations),
)),
...(await Promise.all(req.payload.globals.config.map(generateGlobalOperations))),
...(await Promise.all(collections.map(generateCollectionOperations))),
...(await Promise.all(globals.map(generateGlobalOperations))),
),
components: {
securitySchemes: generateSecuritySchemes(options.authEndpoint),
Expand Down
2 changes: 2 additions & 0 deletions src/openapiPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const openapi =
openapiVersion = '3.0',
metadata,
enabled = true,
filters = {},
}: PluginOptions): Plugin =>
({ endpoints = [], ...config }) => {
if (!enabled) {
Expand All @@ -26,6 +27,7 @@ const openapi =
openapiVersion,
metadata,
authEndpoint,
filters,
}),
},
{
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@ export interface OpenAPIMetadata {
description?: string
}

export interface FilterOptions {
includeCollections?: string[]
excludeCollections?: string[]
hideInternalCollections?: boolean
includeGlobals?: string[]
excludeGlobals?: string[]
}

export interface PluginOptions {
enabled?: boolean
openapiVersion?: OpenAPIVersion
specEndpoint?: string
authEndpoint?: string
metadata: OpenAPIMetadata
filters?: FilterOptions
}

export type SanitizedPluginOptions = Required<Omit<PluginOptions, 'enabled' | 'specEndpoint'>>
Comment thread
janbuchar marked this conversation as resolved.
40 changes: 40 additions & 0 deletions src/utils/filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Collection, SanitizedGlobalConfig } from 'payload'
import type { FilterOptions } from '../types.js'

export const shouldIncludeCollection = (
collection: Collection,
filters: FilterOptions,
): boolean => {
const { slug } = collection.config

if (filters.hideInternalCollections && slug.startsWith('payload-')) {
return false
}

if (filters.excludeCollections?.includes(slug)) {
return false
}

if (filters.includeCollections === undefined) {
return true
}

return filters.includeCollections.includes(slug)
}

export const shouldIncludeGlobal = (
global: SanitizedGlobalConfig,
filters: FilterOptions,
): boolean => {
const { slug } = global

if (filters.excludeGlobals?.includes(slug)) {
return false
}

if (filters.includeGlobals === undefined) {
return true
}

return filters.includeGlobals.includes(slug)
}
Loading