fix: custom endpoints with method: 'put' (#9037)

### What?
Fixes support for custom endpoints with `method: 'put'`.
Previously, this didn't work:
```ts
export default buildConfigWithDefaults({
  collections: [ ],
  endpoints: [
    {
      method: 'put',
      handler: () => new Response(),
      path: '/put',
    },
  ],
})
```

### Why?
We supported this in 2.0 and docs are saying that we can use `'put'` as
`method`
https://payloadcms.com/docs/beta/rest-api/overview#custom-endpoints

### How?
Implements the `REST_PUT` export for `@payloadcms/next/routes`, updates
all templates. Additionally, adds tests to ensure root/collection level
custom endpoints with all necessary methods execute properly.

Fixes https://github.com/payloadcms/payload/issues/8807

-->
This commit is contained in:
Sasha
2024-11-05 23:14:34 +02:00
committed by GitHub
parent f52b7c45c0
commit 9ce2ba6a3f
13 changed files with 218 additions and 9 deletions

View File

@@ -6,4 +6,5 @@ export {
OPTIONS as REST_OPTIONS, OPTIONS as REST_OPTIONS,
PATCH as REST_PATCH, PATCH as REST_PATCH,
POST as REST_POST, POST as REST_POST,
PUT as REST_PUT,
} from '../routes/rest/index.js' } from '../routes/rest/index.js'

View File

@@ -821,3 +821,87 @@ export const PATCH =
}) })
} }
} }
export const PUT =
(config: Promise<SanitizedConfig> | SanitizedConfig) =>
async (request: Request, { params: paramsPromise }: { params: Promise<{ slug: string[] }> }) => {
const { slug } = await paramsPromise
const [slug1] = slug
let req: PayloadRequest
let res: Response
let collection: Collection
try {
req = await createPayloadRequest({
config,
request,
})
collection = req.payload.collections?.[slug1]
const disableEndpoints = endpointsAreDisabled({
endpoints: req.payload.config.endpoints,
request,
})
if (disableEndpoints) {
return disableEndpoints
}
if (collection) {
req.routeParams.collection = slug1
const disableEndpoints = endpointsAreDisabled({
endpoints: collection.config.endpoints,
request,
})
if (disableEndpoints) {
return disableEndpoints
}
const customEndpointResponse = await handleCustomEndpoints({
endpoints: collection.config.endpoints,
entitySlug: slug1,
req,
})
if (customEndpointResponse) {
return customEndpointResponse
}
}
if (res instanceof Response) {
if (req.responseHeaders) {
const mergedResponse = new Response(res.body, {
headers: mergeHeaders(req.responseHeaders, res.headers),
status: res.status,
statusText: res.statusText,
})
return mergedResponse
}
return res
}
// root routes
const customEndpointResponse = await handleCustomEndpoints({
endpoints: req.payload.config.endpoints,
req,
})
if (customEndpointResponse) {
return customEndpointResponse
}
return RouteNotFoundResponse({
slug,
req,
})
} catch (error) {
return routeError({
collection,
config,
err: error,
req: req || request,
})
}
}

View File

@@ -1,10 +1,18 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config) export const GET = REST_GET(config)
export const POST = REST_POST(config) export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config) export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config) export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config) export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1,10 +1,18 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config) export const GET = REST_GET(config)
export const POST = REST_POST(config) export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config) export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config) export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config) export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1,10 +1,18 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config) export const GET = REST_GET(config)
export const POST = REST_POST(config) export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config) export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config) export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config) export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1,10 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config) export const GET = REST_GET(config)
export const POST = REST_POST(config) export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config) export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config) export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config) export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1,10 +1,18 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config) export const GET = REST_GET(config)
export const POST = REST_POST(config) export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config) export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config) export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config) export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1,10 +1,18 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config) export const GET = REST_GET(config)
export const POST = REST_POST(config) export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config) export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config) export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config) export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1,10 +1,18 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config) export const GET = REST_GET(config)
export const POST = REST_POST(config) export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config) export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config) export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config) export const OPTIONS = REST_OPTIONS(config)

View File

@@ -1,10 +1,18 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */ /* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */ /* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config' import config from '@payload-config'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST } from '@payloadcms/next/routes' import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config) export const GET = REST_GET(config)
export const POST = REST_POST(config) export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config) export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config) export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config) export const OPTIONS = REST_OPTIONS(config)

View File

@@ -2,7 +2,7 @@ import { fileURLToPath } from 'node:url'
import path from 'path' import path from 'path'
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename) const dirname = path.dirname(filename)
import { APIError, type CollectionConfig } from 'payload' import { APIError, type CollectionConfig, type Endpoint } from 'payload'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js' import { devUser } from '../credentials.js'
@@ -19,6 +19,8 @@ const openAccess = {
update: () => true, update: () => true,
} }
export const methods: Endpoint['method'][] = ['get', 'delete', 'patch', 'post', 'put']
const collectionWithName = (collectionSlug: string): CollectionConfig => { const collectionWithName = (collectionSlug: string): CollectionConfig => {
return { return {
slug: collectionSlug, slug: collectionSlug,
@@ -39,6 +41,8 @@ export const customIdSlug = 'custom-id'
export const customIdNumberSlug = 'custom-id-number' export const customIdNumberSlug = 'custom-id-number'
export const errorOnHookSlug = 'error-on-hooks' export const errorOnHookSlug = 'error-on-hooks'
export const endpointsSlug = 'endpoints'
export default buildConfigWithDefaults({ export default buildConfigWithDefaults({
admin: { admin: {
importMap: { importMap: {
@@ -257,6 +261,15 @@ export default buildConfigWithDefaults({
], ],
}, },
}, },
{
slug: endpointsSlug,
fields: [],
endpoints: methods.map((method) => ({
method,
handler: () => new Response(`${method} response`),
path: `/${method}-test`,
})),
},
], ],
endpoints: [ endpoints: [
{ {
@@ -296,6 +309,11 @@ export default buildConfigWithDefaults({
method: 'get', method: 'get',
path: '/api-error-here', path: '/api-error-here',
}, },
...methods.map((method) => ({
method,
handler: () => new Response(`${method} response`),
path: `/${method}-test`,
})),
], ],
onInit: async (payload) => { onInit: async (payload) => {
await payload.create({ await payload.create({

View File

@@ -13,7 +13,9 @@ import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { import {
customIdNumberSlug, customIdNumberSlug,
customIdSlug, customIdSlug,
endpointsSlug,
errorOnHookSlug, errorOnHookSlug,
methods,
pointSlug, pointSlug,
relationSlug, relationSlug,
slug, slug,
@@ -1646,6 +1648,25 @@ describe('collections-rest', () => {
).resolves.toBeNull() ).resolves.toBeNull()
}) })
}) })
describe('Custom endpoints', () => {
it('should execute custom root endpoints', async () => {
for (const method of methods) {
const response = await restClient[method.toUpperCase()](`/${method}-test`, {})
await expect(response.text()).resolves.toBe(`${method} response`)
}
})
it('should execute custom collection endpoints', async () => {
for (const method of methods) {
const response = await restClient[method.toUpperCase()](
`/${endpointsSlug}/${method}-test`,
{},
)
await expect(response.text()).resolves.toBe(`${method} response`)
}
})
})
}) })
async function createPost(overrides?: Partial<Post>) { async function createPost(overrides?: Partial<Post>) {

View File

@@ -7,6 +7,7 @@ import {
GRAPHQL_POST as createGraphqlPOST, GRAPHQL_POST as createGraphqlPOST,
REST_PATCH as createPATCH, REST_PATCH as createPATCH,
REST_POST as createPOST, REST_POST as createPOST,
REST_PUT as createPUT,
} from '@payloadcms/next/routes' } from '@payloadcms/next/routes'
import * as qs from 'qs-esm' import * as qs from 'qs-esm'
@@ -67,6 +68,11 @@ export class NextRESTClient {
args: { params: Promise<{ slug: string[] }> }, args: { params: Promise<{ slug: string[] }> },
) => Promise<Response> ) => Promise<Response>
private _PUT: (
request: Request,
args: { params: Promise<{ slug: string[] }> },
) => Promise<Response>
private readonly config: SanitizedConfig private readonly config: SanitizedConfig
private token: string private token: string
@@ -82,6 +88,7 @@ export class NextRESTClient {
this._POST = createPOST(config) this._POST = createPOST(config)
this._DELETE = createDELETE(config) this._DELETE = createDELETE(config)
this._PATCH = createPATCH(config) this._PATCH = createPATCH(config)
this._PUT = createPUT(config)
this._GRAPHQL_POST = createGraphqlPOST(config) this._GRAPHQL_POST = createGraphqlPOST(config)
} }
@@ -221,4 +228,17 @@ export class NextRESTClient {
}) })
return this._POST(request, { params: Promise.resolve({ slug }) }) return this._POST(request, { params: Promise.resolve({ slug }) })
} }
async PUT(path: ValidPath, options: FileArg & RequestInit & RequestOptions): Promise<Response> {
const { slug, params, url } = this.generateRequestParts(path)
const { query, ...rest } = options
const queryParams = generateQueryString(query, params)
const request = new Request(`${url}${queryParams}`, {
...rest,
headers: this.buildHeaders(options),
method: 'PUT',
})
return this._PUT(request, { params: Promise.resolve({ slug }) })
}
} }