diff --git a/README.md b/README.md index 28915de..be98fc5 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 . If you diff --git a/src/openapi/generators.ts b/src/openapi/generators.ts index 2a89e58..e2325bb 100644 --- a/src/openapi/generators.ts +++ b/src/openapi/generators.ts @@ -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' @@ -585,7 +586,10 @@ const generateGlobalOperations = async ( } } -const generateComponents = (req: Pick) => { +const generateComponents = ( + req: Pick, + options: SanitizedPluginOptions, +) => { const schemas: Record = { supportedTimezones: { type: 'string', @@ -593,7 +597,15 @@ const generateComponents = (req: Pick) => { }, } - 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, @@ -601,17 +613,17 @@ const generateComponents = (req: Pick) => { ) } - 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 = {} - 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, @@ -622,15 +634,15 @@ const generateComponents = (req: Pick) => { 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 = 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), })), ) @@ -642,7 +654,13 @@ export const generateV30Spec = async ( req: Pick, options: SanitizedPluginOptions, ): Promise => { - 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', @@ -650,10 +668,8 @@ export const generateV30Spec = async ( 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), @@ -702,7 +718,13 @@ export const generateV31Spec = async ( req: Pick, options: SanitizedPluginOptions, ): Promise => { - 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', @@ -710,10 +732,8 @@ export const generateV31Spec = async ( 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), diff --git a/src/openapiPlugin.ts b/src/openapiPlugin.ts index d8dd9dd..1d85d47 100644 --- a/src/openapiPlugin.ts +++ b/src/openapiPlugin.ts @@ -9,6 +9,7 @@ const openapi = openapiVersion = '3.0', metadata, enabled = true, + filters = {}, }: PluginOptions): Plugin => ({ endpoints = [], ...config }) => { if (!enabled) { @@ -26,6 +27,7 @@ const openapi = openapiVersion, metadata, authEndpoint, + filters, }), }, { diff --git a/src/types.ts b/src/types.ts index 7ebbf0c..a2c1244 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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> diff --git a/src/utils/filters.ts b/src/utils/filters.ts new file mode 100644 index 0000000..d775a99 --- /dev/null +++ b/src/utils/filters.ts @@ -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) +} diff --git a/test/openapi-generators.test.ts b/test/openapi-generators.test.ts index 6f0298a..f73c040 100644 --- a/test/openapi-generators.test.ts +++ b/test/openapi-generators.test.ts @@ -2,8 +2,16 @@ import { mongooseAdapter } from '@payloadcms/db-mongodb' import { lexicalEditor } from '@payloadcms/richtext-lexical' import { MongoMemoryServer } from 'mongodb-memory-server' import mongoose from 'mongoose' -import { BasePayload, buildConfig, type CollectionConfig, type Config, type Payload } from 'payload' +import { + BasePayload, + buildConfig, + type CollectionConfig, + type Config, + type GlobalConfig, + type Payload, +} from 'payload' import { afterEach, beforeAll, beforeEach, describe, expect, test } from 'vitest' + import { generateV30Spec } from '../src/openapi/generators' const Posts: CollectionConfig = { @@ -55,6 +63,7 @@ describe('openapi generators', () => { openapiVersion: '3.0', authEndpoint: '/api/auth', metadata: { title: 'Test API', version: '1.0' }, + filters: {}, }, ) @@ -85,6 +94,7 @@ describe('openapi generators', () => { openapiVersion: '3.0', authEndpoint: '/api/auth', metadata: { title: 'Test API', version: '1.0' }, + filters: {}, }, ) @@ -134,6 +144,7 @@ describe('openapi generators', () => { openapiVersion: '3.0', authEndpoint: '/api/auth', metadata: { title: 'Test API', version: '1.0' }, + filters: {}, }, ) @@ -167,6 +178,7 @@ describe('openapi generators', () => { openapiVersion: '3.0', authEndpoint: '/api/auth', metadata: { title: 'Test API', version: '1.0' }, + filters: {}, }, ) @@ -194,9 +206,149 @@ describe('openapi generators', () => { openapiVersion: '3.0', authEndpoint: '/api/auth', metadata: { title: 'Test API', version: '1.0' }, + filters: {}, }, ) expect(spec).toMatchSnapshot() }) + + describe('collection filtering', () => { + test('includeCollections filters to specified collections only', async () => { + const Categories: CollectionConfig = { + slug: 'categories', + fields: [{ type: 'text', name: 'name' }], + } + const payload = await buildPayload({ + collections: [Posts, Categories], + }) + + const spec = await generateV30Spec( + { protocol: 'https', headers: new Headers({ host: 'localhost' }), payload }, + { + openapiVersion: '3.0', + authEndpoint: '/api/auth', + metadata: { title: 'Test API', version: '1.0' }, + filters: { includeCollections: ['posts'] }, + }, + ) + + expect(new Set(Object.keys(spec.paths))).toEqual(new Set(['/api/posts', '/api/posts/{id}'])) + expect(spec.paths['/api/categories']).toBeUndefined() + }) + + test('excludeCollections excludes specified collections', async () => { + const payload = await buildPayload({ + collections: [Posts], + }) + + const spec = await generateV30Spec( + { protocol: 'https', headers: new Headers({ host: 'localhost' }), payload }, + { + openapiVersion: '3.0', + authEndpoint: '/api/auth', + metadata: { title: 'Test API', version: '1.0' }, + filters: { excludeCollections: ['users'] }, + }, + ) + + expect(spec.paths['/api/posts']).toBeDefined() + expect(spec.paths['/api/users']).toBeUndefined() + }) + + test('hideInternalCollections removes payload-* collections from spec', async () => { + const payload = await buildPayload({ + collections: [Posts], + }) + + const spec = await generateV30Spec( + { protocol: 'https', headers: new Headers({ host: 'localhost' }), payload }, + { + openapiVersion: '3.0', + authEndpoint: '/api/auth', + metadata: { title: 'Test API', version: '1.0' }, + filters: { hideInternalCollections: true }, + }, + ) + + expect(spec.paths['/api/posts']).toBeDefined() + expect(spec.paths['/api/payload-preferences']).toBeUndefined() + expect(spec.paths['/api/payload-migrations']).toBeUndefined() + expect(spec.paths['/api/payload-locked-documents']).toBeUndefined() + }) + + test('empty includeCollections array results in no collections', async () => { + const payload = await buildPayload({ + collections: [Posts], + }) + + const spec = await generateV30Spec( + { protocol: 'https', headers: new Headers({ host: 'localhost' }), payload }, + { + openapiVersion: '3.0', + authEndpoint: '/api/auth', + metadata: { title: 'Test API', version: '1.0' }, + filters: { includeCollections: [] }, + }, + ) + + expect(Object.keys(spec.paths).filter(path => path.startsWith('/api/'))).toEqual([]) + }) + }) + + describe('global filtering', () => { + test('includeGlobals filters to specified globals only', async () => { + const Settings: GlobalConfig = { + slug: 'settings', + fields: [{ type: 'text', name: 'siteName' }], + } + const Footer: GlobalConfig = { + slug: 'footer', + fields: [{ type: 'text', name: 'copyright' }], + } + const payload = await buildPayload({ + globals: [Settings, Footer], + }) + + const spec = await generateV30Spec( + { protocol: 'https', headers: new Headers({ host: 'localhost' }), payload }, + { + openapiVersion: '3.0', + authEndpoint: '/api/auth', + metadata: { title: 'Test API', version: '1.0' }, + filters: { includeGlobals: ['settings'] }, + }, + ) + + expect(spec.paths['/api/globals/settings']).toBeDefined() + expect(spec.paths['/api/globals/footer']).toBeUndefined() + }) + + test('excludeGlobals excludes specified globals', async () => { + const Settings: GlobalConfig = { + slug: 'settings', + fields: [{ type: 'text', name: 'siteName' }], + } + const Footer: GlobalConfig = { + slug: 'footer', + fields: [{ type: 'text', name: 'copyright' }], + } + const payload = await buildPayload({ + globals: [Settings, Footer], + }) + + const spec = await generateV30Spec( + { protocol: 'https', headers: new Headers({ host: 'localhost' }), payload }, + { + openapiVersion: '3.0', + authEndpoint: '/api/auth', + metadata: { title: 'Test API', version: '1.0' }, + filters: { excludeGlobals: ['footer'] }, + }, + ) + + expect(spec.paths['/api/globals/settings']).toBeDefined() + expect(spec.paths['/api/globals/footer']).toBeUndefined() + }) + }) })