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
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ openapi({
})
```

## 4. Override the API base path (optional)

Generated operation paths are prefixed with your Payload `routes.api` setting. Set `apiBasePath` if clients reach the
API under a different prefix, e.g. behind a reverse proxy:

```typescript
openapi({
openapiVersion: '3.0',
metadata: { title: 'Dev API', version: '0.0.1' },
apiBasePath: '/public-api',
})
```

# Usage

Unless you configured it otherwise, your spec will be accessible via <https://your-payload.com/api/openapi.json>. If you
Expand Down
26 changes: 17 additions & 9 deletions src/openapi/generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ const isOpenToPublic = async (checker: Access): Promise<boolean> => {
const generateCollectionOperations = async (
config: SanitizedConfig,
collection: Collection,
apiRoute: string,
): Promise<Record<string, OpenAPIV3.PathItemObject & OpenAPIV3_1.PathItemObject>> => {
const { slug } = collection.config
const { singular, plural } = collectionName(collection)
Expand All @@ -413,7 +414,7 @@ const generateCollectionOperations = async (
} satisfies OpenAPIV3_1.ResponsesObject & OpenAPIV3.ResponsesObject

return {
[`/api/${slug}`]: {
[`${apiRoute}/${slug}`]: {
get: {
operationId: componentName('schemas', plural, { prefix: 'list' }),
summary: `Retrieve a list of ${plural}`,
Expand Down Expand Up @@ -475,7 +476,7 @@ const generateCollectionOperations = async (
security: (await isOpenToPublic(collection.config.access.create)) ? [] : [apiKeySecurity],
},
},
[`/api/${slug}/{id}`]: {
[`${apiRoute}/${slug}/{id}`]: {
parameters: [
...baseQueryParams,
{
Expand Down Expand Up @@ -574,13 +575,14 @@ const generateGlobalSchemas = (

const generateGlobalOperations = async (
global: SanitizedGlobalConfig,
apiRoute: string,
): Promise<Record<string, OpenAPIV3.PathItemObject & OpenAPIV3_1.PathItemObject>> => {
const slug = global.slug
const singular = globalName(global)
const tags = [singular]

return {
[`/api/globals/${slug}`]: {
[`${apiRoute}/globals/${slug}`]: {
get: {
summary: `Get the ${singular}`,
tags,
Expand Down Expand Up @@ -673,6 +675,7 @@ export const generateV30Spec = async (
shouldIncludeCollection(collection, filters),
)
const globals = req.payload.globals.config.filter(global => shouldIncludeGlobal(global, filters))
const apiRoute = options.apiBasePath ?? req.payload.config.routes.api

const spec = {
openapi: '3.0.3',
Expand All @@ -681,12 +684,14 @@ export const generateV30Spec = async (
paths: Object.assign(
{},
...(await Promise.all(
collections.map(collection => generateCollectionOperations(req.payload.config, collection)),
collections.map(collection =>
generateCollectionOperations(req.payload.config, collection, apiRoute),
),
)),
...(await Promise.all(globals.map(generateGlobalOperations))),
...(await Promise.all(globals.map(global => generateGlobalOperations(global, apiRoute)))),
),
components: {
securitySchemes: generateSecuritySchemes(options.authEndpoint),
securitySchemes: generateSecuritySchemes(options.authEndpoint, apiRoute),
schemas: await mapValuesAsync(jsonSchemaToOpenapiSchema, schemas),
requestBodies: await mapValuesAsync(
async requestBody => ({
Expand Down Expand Up @@ -739,6 +744,7 @@ export const generateV31Spec = async (
shouldIncludeCollection(collection, filters),
)
const globals = req.payload.globals.config.filter(global => shouldIncludeGlobal(global, filters))
const apiRoute = options.apiBasePath ?? req.payload.config.routes.api

const spec = {
openapi: '3.1.0',
Expand All @@ -747,12 +753,14 @@ export const generateV31Spec = async (
paths: Object.assign(
{},
...(await Promise.all(
collections.map(collection => generateCollectionOperations(req.payload.config, collection)),
collections.map(collection =>
generateCollectionOperations(req.payload.config, collection, apiRoute),
),
)),
...(await Promise.all(globals.map(generateGlobalOperations))),
...(await Promise.all(globals.map(global => generateGlobalOperations(global, apiRoute)))),
),
components: {
securitySchemes: generateSecuritySchemes(options.authEndpoint),
securitySchemes: generateSecuritySchemes(options.authEndpoint, apiRoute),
schemas: schemas as Record<string, OpenAPIV3_1.SchemaObject>,
requestBodies,
responses,
Expand Down
6 changes: 4 additions & 2 deletions src/openapi/securitySchemes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import type { OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'
export const apiKeySecurity = { ApiKey: [] }

export const generateSecuritySchemes = (
tokenUrl: string,
authEndpoint: string,
apiRoute: string,
): Record<string, OpenAPIV3.SecuritySchemeObject & OpenAPIV3_1.SecuritySchemeObject> => ({
ApiKey: {
type: 'oauth2',
flows: {
password: {
tokenUrl: `/api/${tokenUrl}`,
// Payload mounts plugin endpoints under `routes.api`.
tokenUrl: `${apiRoute}${authEndpoint}`,
scopes: {},
},
},
Expand Down
2 changes: 2 additions & 0 deletions src/openapiPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const openapi =
metadata,
enabled = true,
filters = {},
apiBasePath = null,
}: PluginOptions): Plugin =>
({ endpoints = [], ...config }) => {
if (!enabled) {
Expand All @@ -28,6 +29,7 @@ const openapi =
metadata,
authEndpoint,
filters,
apiBasePath,
}),
},
{
Expand Down
7 changes: 5 additions & 2 deletions src/rapidocPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Plugin } from 'payload'
import { apiRoute } from './utils/routes.js'

const rapidoc =
({
specEndpoint = '/api/openapi.json',
specEndpoint,
docsUrl = '/docs',
enabled = true,
}: {
Expand All @@ -15,6 +16,8 @@ const rapidoc =
return { ...config, endpoints }
}

const specUrl = specEndpoint ?? `${apiRoute(config)}/openapi.json`

return {
...config,
endpoints: [
Expand All @@ -38,7 +41,7 @@ const rapidoc =
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/rapidoc@9.3.8/dist/rapidoc-min.js" type="module"></script>
<rapi-doc spec-url="${req.protocol}//${req.headers.get('host')}${specEndpoint}"></rapi-doc>
<rapi-doc spec-url="${req.protocol}//${req.headers.get('host')}${specUrl}"></rapi-doc>
</body>
</html>`,
{ headers: { 'content-type': 'text/html' } },
Expand Down
8 changes: 6 additions & 2 deletions src/redocPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Plugin } from 'payload'
import { apiRoute } from './utils/routes.js'

const redoc =
({
specEndpoint = '/api/openapi.json',
specEndpoint,
docsUrl = '/docs',
enabled = true,
}: {
Expand All @@ -14,6 +15,9 @@ const redoc =
if (!enabled) {
return { ...config, endpoints }
}

const specUrl = specEndpoint ?? `${apiRoute(config)}/openapi.json`

return {
...config,
endpoints: [
Expand Down Expand Up @@ -43,7 +47,7 @@ const redoc =
</style>
</head>
<body>
<redoc spec-url="${req.protocol}//${req.headers.get('host')}${specEndpoint}"></redoc>
<redoc spec-url="${req.protocol}//${req.headers.get('host')}${specUrl}"></redoc>
<script src="https://cdn.jsdelivr.net/npm/redoc@2.4.0/bundles/redoc.standalone.js"></script>
</body>
</html>`,
Expand Down
7 changes: 5 additions & 2 deletions src/scalarPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Plugin } from 'payload'
import { apiRoute } from './utils/routes.js'

const scalar =
({
specEndpoint = '/api/openapi.json',
specEndpoint,
docsUrl = '/docs',
enabled = true,
}: {
Expand All @@ -15,6 +16,8 @@ const scalar =
return { ...config, endpoints }
}

const specUrl = specEndpoint ?? `${apiRoute(config)}/openapi.json`

return {
...config,
endpoints: [
Expand All @@ -23,7 +26,7 @@ const scalar =
method: 'get',
path: docsUrl,
handler: async req => {
const fullSpecUrl = `${req.protocol}//${req.headers.get('host')}${specEndpoint}`
const fullSpecUrl = `${req.protocol}//${req.headers.get('host')}${specUrl}`

const html = `
<!DOCTYPE html>
Expand Down
8 changes: 6 additions & 2 deletions src/swaggerUIPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { Config, Plugin } from 'payload'
import { apiRoute } from './utils/routes.js'

const swaggerUI =
({
specEndpoint = '/api/openapi.json',
specEndpoint,
docsUrl = '/docs',
enabled = true,
}: {
Expand All @@ -14,6 +15,9 @@ const swaggerUI =
if (!enabled) {
return { ...config, endpoints }
}

const specUrl = specEndpoint ?? `${apiRoute(config)}/openapi.json`

return {
...config,
endpoints: [
Expand Down Expand Up @@ -42,7 +46,7 @@ const swaggerUI =
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '${req.protocol}//${req.headers.get('host')}${specEndpoint}',
url: '${req.protocol}//${req.headers.get('host')}${specUrl}',
dom_id: '#swagger-ui',
});
};
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export interface PluginOptions {
authEndpoint?: string
metadata: OpenAPIMetadata
filters?: FilterOptions
/** Path prefix for generated operations, defaults to the Payload `routes.api` setting. */
apiBasePath?: string | null
}

export type SanitizedPluginOptions = Required<Omit<PluginOptions, 'enabled' | 'specEndpoint'>>
4 changes: 4 additions & 0 deletions src/utils/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { Config } from 'payload'

/** Plugins run before the config is sanitized, so `routes.api` may still be unset. */
export const apiRoute = ({ routes }: Pick<Config, 'routes'>): string => routes?.api ?? '/api'
Loading
Loading