Custom collection/global endpoints (#607)

* feat: add custom endpoints for collections and globals REST APIs

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
GeorgeyB
2022-05-30 17:43:18 +01:00
committed by GitHub
parent 20bbda95c6
commit 7ee374ea1a
21 changed files with 311 additions and 36 deletions

View File

@@ -0,0 +1,61 @@
import { Response } from 'express';
import { CollectionConfig } from '../../src/collections/config/types';
import { PayloadRequest } from '../../src/express/types';
const Endpoints: CollectionConfig = {
slug: 'endpoints',
labels: {
singular: 'Endpoint',
plural: 'Endpoints',
},
admin: {
useAsTitle: 'title',
},
access: {
create: () => true,
read: () => true,
update: () => true,
delete: () => true,
},
endpoints: [
{
path: '/say-hello/joe-bloggs',
method: 'get',
handler: (req: PayloadRequest, res: Response): void => {
res.json({ message: `Hey Joey! Welcome to ${req.payload.getAPIURL()}` });
},
},
{
path: '/say-hello/:group/:name',
method: 'get',
handler: (req: PayloadRequest, res: Response): void => {
res.json({ message: `Hello ${req.params.name} @ ${req.params.group}` });
},
},
{
path: '/say-hello/:name',
method: 'get',
handler: (req: PayloadRequest, res: Response): void => {
res.json({ message: `Hello ${req.params.name}!` });
},
},
{
path: '/whoami',
method: 'post',
handler: (req: PayloadRequest, res: Response): void => {
res.json({
name: req.body.name,
age: req.body.age,
});
},
},
],
fields: [
{
name: 'title',
type: 'text',
},
],
};
export default Endpoints;

View File

@@ -1,5 +1,8 @@
import { Response } from 'express';
import { GlobalConfig } from '../../src/globals/config/types';
import checkRole from '../access/checkRole';
import { NavigationArray as TNavigationArray } from '../payload-types';
import { PayloadRequest } from '../../src/express/types';
const NavigationArray: GlobalConfig = {
slug: 'navigation-array',
@@ -10,6 +13,19 @@ const NavigationArray: GlobalConfig = {
admin: {
description: 'A description for the editor',
},
endpoints: [
{
path: '/count',
method: 'get',
handler: async (req: PayloadRequest, res: Response): Promise<void> => {
const { array } = await req.payload.findGlobal<TNavigationArray>({
slug: 'navigation-array',
});
res.json({ count: array.length });
},
},
],
fields: [
{
name: 'array',

View File

@@ -147,6 +147,10 @@ export interface AllFields {
checkbox?: boolean;
id?: string;
}[];
readOnlyArray?: {
text?: string;
id?: string;
}[];
blocks: (
| {
testEmail: string;
@@ -283,6 +287,7 @@ export interface Conditions {
blockType: 'cta';
}
)[];
customComponent: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
@@ -482,6 +487,14 @@ export interface DefaultValueTest {
asyncText?: string;
function?: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "endpoints".
*/
export interface Endpoint {
id: string;
title?: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "files".

View File

@@ -8,6 +8,7 @@ import Autosave from './collections/Autosave';
import Code from './collections/Code';
import Conditions from './collections/Conditions';
// import CustomComponents from './collections/CustomComponents';
import Endpoints from './collections/Endpoints';
import File from './collections/File';
import Blocks from './collections/Blocks';
import CustomID from './collections/CustomID';
@@ -100,6 +101,7 @@ export default buildConfig({
// CustomComponents,
CustomID,
DefaultValues,
Endpoints,
File,
Geolocation,
HiddenFields,

View File

@@ -25,6 +25,7 @@ It's often best practice to write your Collections in separate files and then im
| **`upload`** | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](/docs/upload/overview) documentation. |
| **`timestamps`** | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
| **`versions`** | Set to true to enable default options, or configure with object properties. [More](/docs/versions/overview#collection-config)|
| **`endpoints`** | Add custom routes to the REST API. [More](/docs/rest-api#custom-endpoints) |
*\* An asterisk denotes that a property is required.*
@@ -80,25 +81,25 @@ If the function is specified, a Preview button will automatically appear in the
**Example collection with preview function:**
```js
{
const Posts = {
slug: 'posts',
fields: [
{
name: 'slug',
type: 'text',
required: true,
}
]
},
],
admin: {
preview: (doc, { locale }) => {
if (doc?.slug) {
return `https://bigbird.com/preview/posts/${doc.slug}?locale=${locale}`,
return `https://bigbird.com/preview/posts/${doc.slug}?locale=${locale}`;
}
return null;
}
}
}
},
},
};
```
### Access control

View File

@@ -22,6 +22,7 @@ As with Collection configs, it's often best practice to write your Globals in se
| **`hooks`** | Entry points to "tie in" to collection actions at specific points. [More](/docs/hooks/overview#global-hooks) |
| **`access`** | Provide access control functions to define exactly who should be able to do what with this Global. [More](/docs/access-control/overview/#globals) |
| **`versions`** | Set to true to enable default options, or configure with object properties. [More](/docs/versions/overview#globals-config)|
| **`endpoints`** | Add custom routes to the REST API. [More](/docs/rest-api#custom-endpoints)|
*\* An asterisk denotes that a property is required.*

View File

@@ -43,6 +43,8 @@ The `find` endpoint supports the following additional query parameters:
## Auth Operations
Auth enabled collections are also given the following endpoints:
| Method | Path | Description |
| -------- | --------------------------- | ----------- |
| `POST` | `/api/{collectionSlug}/verify/:token` | [Email verification](/docs/authentication/operations#verify-by-email), if enabled. |
@@ -72,3 +74,47 @@ In addition to the dynamically generated endpoints above Payload also has REST e
| `GET` | `/api/_preferences/{key}` | Get a preference by key |
| `POST` | `/api/_preferences/{key}` | Create or update by key |
| `DELETE` | `/api/_preferences/{key}` | Delete a user preference by key |
## Custom Endpoints
Additional REST API endpoints can be added to `collections` and `globals` by providing array of `endpoints` in the configuration. These can be used to write additional middleware on existing routes or build custom functionality into Payload apps and plugins.
Each endpoint object needs to have:
| Property | Description |
| ---------- | ----------------------------------------- |
| **`path`** | A string for the endpoint route after the collection or globals slug |
| **`method`** | The lowercase HTTP verb to use: 'get', 'head', 'post', 'put', 'delete', 'connect' or 'options' |
| **`handler`** | A function or array of functions to be called with **req**, **res** and **next** arguments. [Express](https://expressjs.com/en/guide/routing.html#route-handlers) |
Example:
```js
// a collection of 'orders' with an additional route for tracking details, reachable at /api/orders/:id/tracking
const Orders = {
slug: 'orders',
fields: [ /* ... */ ],
// highlight-start
endpoints: [
{
path: '/:id/tracking',
method: 'get',
handler: async (req, res, next) => {
const tracking = await getTrackingInfo(req.params.id);
if (tracking) {
res.status('200').send({ tracking });
} else {
res.status('404').send({ error: 'not found' });
}
}
}
],
// highlight-end
}
```
<Banner>
<strong>Note:</strong><br/>
**req** will have the **payload** object and can be used inside your endpoint handlers for making calls like req.payload.find() that will make use of access control and hooks.
</Banner>

View File

@@ -3,7 +3,9 @@ import { PayloadRequest } from '../../express/types';
export default async function me(req: PayloadRequest, res: Response, next: NextFunction): Promise<any> {
try {
const collectionSlug = req.route.path.split('/').filter((r) => r !== '')[0];
const collectionSlugMatch = req.originalUrl.match(/\/([^/]+)\/me\/?$/);
const [, collectionSlug] = collectionSlugMatch;
const response = await this.operations.collections.auth.me({
req,
collectionSlug,

View File

@@ -32,6 +32,7 @@ export const defaults = {
afterLogin: [],
afterForgotPassword: [],
},
endpoints: [],
auth: false,
upload: false,
versions: false,

View File

@@ -53,6 +53,14 @@ const collectionSchema = joi.object().keys({
afterLogin: joi.array().items(joi.func()),
afterForgotPassword: joi.array().items(joi.func()),
}),
endpoints: joi.array().items(joi.object({
path: joi.string(),
method: joi.string().valid('get', 'head', 'post', 'put', 'patch', 'delete', 'connect', 'options'),
handler: joi.alternatives().try(
joi.array().items(joi.func()),
joi.func(),
),
})),
auth: joi.alternatives().try(
joi.object({
tokenExpiration: joi.number(),

View File

@@ -2,7 +2,7 @@
import { DeepRequired } from 'ts-essentials';
import { PaginateModel } from 'mongoose';
import { GraphQLType } from 'graphql';
import { Access, GeneratePreviewURL, EntityDescription } from '../../config/types';
import { Access, GeneratePreviewURL, EntityDescription, Endpoint } from '../../config/types';
import { Field } from '../../fields/config/types';
import { PayloadRequest } from '../../express/types';
import { IncomingAuthType, Auth } from '../../auth/types';
@@ -193,6 +193,10 @@ export type CollectionConfig = {
afterLogin?: AfterLoginHook[];
afterForgotPassword?: AfterForgotPasswordHook[];
};
/**
* Custom rest api endpoints
*/
endpoints?: Endpoint[]
/**
* Access control
*/

View File

@@ -14,6 +14,7 @@ import bindCollectionMiddleware from './bindCollection';
import { CollectionModel, SanitizedCollectionConfig } from './config/types';
import { Payload } from '../index';
import { getVersionsModelName } from '../versions/getVersionsModelName';
import mountEndpoints from '../init/mountEndpoints';
const LocalStrategy = Passport.Strategy;
@@ -95,9 +96,11 @@ export default function registerCollections(ctx: Payload): void {
// If not local, open routes
if (!ctx.local) {
const router = express.Router();
const { slug } = collection;
const { slug, endpoints } = collection;
router.all(`/${slug}*`, bindCollectionMiddleware(ctx.collections[formattedCollection.slug]));
router.all('*', bindCollectionMiddleware(ctx.collections[formattedCollection.slug]));
mountEndpoints(router, endpoints);
const {
create,
@@ -133,68 +136,68 @@ export default function registerCollections(ctx: Payload): void {
if (collection.auth.verify) {
router
.route(`/${slug}/verify/:token`)
.route('/verify/:token')
.post(verifyEmail);
}
if (collection.auth.maxLoginAttempts > 0) {
router
.route(`/${slug}/unlock`)
.route('/unlock')
.post(unlock);
}
router
.route(`/${slug}/init`)
.route('/init')
.get(init);
router
.route(`/${slug}/login`)
.route('/login')
.post(login);
router
.route(`/${slug}/logout`)
.route('/logout')
.post(logout);
router
.route(`/${slug}/refresh-token`)
.route('/refresh-token')
.post(refresh);
router
.route(`/${slug}/me`)
.route('/me')
.get(me);
router
.route(`/${slug}/first-register`)
.route('/first-register')
.post(registerFirstUser);
router
.route(`/${slug}/forgot-password`)
.route('/forgot-password')
.post(forgotPassword);
router
.route(`/${slug}/reset-password`)
.route('/reset-password')
.post(resetPassword);
}
if (collection.versions) {
router.route(`/${slug}/versions`)
router.route('/versions')
.get(findVersions);
router.route(`/${slug}/versions/:id`)
router.route('/versions/:id')
.get(findVersionByID)
.post(restoreVersion);
}
router.route(`/${slug}`)
router.route('/')
.get(find)
.post(create);
router.route(`/${slug}/:id`)
router.route('/:id')
.put(update)
.get(findByID)
.delete(deleteHandler);
ctx.router.use(router);
ctx.router.use(`/${slug}`, router);
}
return formattedCollection;

View File

@@ -0,0 +1,73 @@
import getConfig from '../../config/load';
import { email, password } from '../../mongoose/testCredentials';
require('isomorphic-fetch');
const { serverURL: url } = getConfig();
let token = null;
let headers = null;
describe('Collections - REST', () => {
beforeAll(async (done) => {
const response = await fetch(`${url}/api/admins/login`, {
body: JSON.stringify({
email,
password,
}),
headers: {
'Content-Type': 'application/json',
},
method: 'post',
});
const data = await response.json();
({ token } = data);
headers = {
Authorization: `JWT ${token}`,
'Content-Type': 'application/json',
};
done();
});
describe('Endpoints', () => {
it('should GET a static endpoint', async () => {
const response = await fetch(`${url}/api/endpoints/say-hello/joe-bloggs`, {
headers: {
...headers,
},
method: 'get',
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toStrictEqual(`Hey Joey! Welcome to ${url}/api`);
});
it('should GET an endpoint with a parameter', async () => {
const response = await fetch(`${url}/api/endpoints/say-hello/George`, {
headers: {
...headers,
},
method: 'get',
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toStrictEqual('Hello George!');
});
it('should POST an endpoint with data', async () => {
const params = { name: 'George', age: 29 };
const response = await fetch(`${url}/api/endpoints/whoami`, {
body: JSON.stringify(params),
headers: {
...headers,
},
method: 'post',
});
const data = await response.json();
expect(response.status).toBe(200);
expect(data.name).toStrictEqual(params.name);
expect(data.age).toStrictEqual(params.age);
});
});
});

View File

@@ -1,4 +1,4 @@
import { Express } from 'express';
import { Express, Handler } from 'express';
import { DeepRequired } from 'ts-essentials';
import { Transporter } from 'nodemailer';
import { Options } from 'express-fileupload';
@@ -79,6 +79,12 @@ export type AccessResult = boolean | Where;
*/
export type Access = (args?: any) => AccessResult | Promise<AccessResult>;
export type Endpoint = {
path: string
method: 'get' | 'head' | 'post' | 'put' | 'patch' | 'delete' | 'connect' | 'options' | string
handler: Handler | Handler[]
}
export type AdminView = React.ComponentType<{ user: User, canAccessAdmin: boolean }>
export type AdminRoute = {

View File

@@ -24,7 +24,6 @@ const validateFields = (context: string, entity: SanitizedCollectionConfig | San
});
}
if (result.error) {
console.log(result.error);
result.error.details.forEach(({ message }) => {
errors.push(`${context} "${entity.slug}" > Field${fieldAffectsData(field) ? ` "${field.name}" >` : ''} ${message}`);
});

View File

@@ -19,6 +19,7 @@ const sanitizeGlobals = (collections: CollectionConfig[], globals: GlobalConfig[
// /////////////////////////////////
if (!sanitizedGlobal.hooks) sanitizedGlobal.hooks = {};
if (!sanitizedGlobal.endpoints) sanitizedGlobal.endpoints = [];
if (!sanitizedGlobal.access) sanitizedGlobal.access = {};
if (!sanitizedGlobal.admin) sanitizedGlobal.admin = {};

View File

@@ -18,6 +18,14 @@ const globalSchema = joi.object().keys({
beforeRead: joi.array().items(joi.func()),
afterRead: joi.array().items(joi.func()),
}),
endpoints: joi.array().items(joi.object({
path: joi.string(),
method: joi.string().valid('get', 'head', 'post', 'put', 'patch', 'delete', 'connect', 'options'),
handler: joi.alternatives().try(
joi.array().items(joi.func()),
joi.func(),
),
})),
access: joi.object({
read: joi.func(),
readVersions: joi.func(),

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Model, Document } from 'mongoose';
import { DeepRequired } from 'ts-essentials';
import { PayloadRequest } from '../../express/types';
import { Access, GeneratePreviewURL } from '../../config/types';
import { Access, Endpoint, GeneratePreviewURL } from '../../config/types';
import { Field } from '../../fields/config/types';
import { IncomingGlobalVersions, SanitizedGlobalVersions } from '../../versions/types';
@@ -56,6 +56,7 @@ export type GlobalConfig = {
beforeRead?: BeforeReadHook[]
afterRead?: AfterReadHook[]
}
endpoints?: Endpoint[],
access?: {
read?: Access;
readDrafts?: Access;

View File

@@ -8,6 +8,7 @@ import { getVersionsModelName } from '../versions/getVersionsModelName';
import { buildVersionGlobalFields } from '../versions/buildGlobalFields';
import buildSchema from '../mongoose/buildSchema';
import { CollectionModel } from '../collections/config/types';
import mountEndpoints from '../init/mountEndpoints';
export default function initGlobals(ctx: Payload): void {
if (ctx.config.globals) {
@@ -40,25 +41,28 @@ export default function initGlobals(ctx: Payload): void {
// If not local, open routes
if (!ctx.local) {
const router = express.Router();
ctx.config.globals.forEach((global) => {
const router = express.Router();
const { slug, endpoints } = global;
mountEndpoints(router, endpoints);
router
.route(`/globals/${global.slug}`)
.route('/')
.get(ctx.requestHandlers.globals.findOne(global))
.post(ctx.requestHandlers.globals.update(global));
if (global.versions) {
router.route(`/globals/${global.slug}/versions`)
router.route('/versions')
.get(ctx.requestHandlers.globals.findVersions(global));
router.route(`/globals/${global.slug}/versions/:id`)
router.route('/versions/:id')
.get(ctx.requestHandlers.globals.findVersionByID(global))
.post(ctx.requestHandlers.globals.restoreVersion(global));
}
});
ctx.router.use(router);
ctx.router.use(`/globals/${slug}`, router);
});
}
}
}

View File

@@ -174,4 +174,19 @@ describe('Globals - REST', () => {
expect(textarea).toStrictEqual(navData.es.nav1.textarea);
});
});
describe('Endpoints', () => {
it('should respond with number of navigation items', async () => {
const response = await fetch(`${url}/api/globals/navigation-array/count`, {
headers: {
Authorization: `JWT ${token}`,
'Content-Type': 'application/json',
},
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.count).toStrictEqual(2);
});
});
});

View File

@@ -0,0 +1,10 @@
import { Router } from 'express';
import { Endpoint } from '../config/types';
function mountEndpoints(router: Router, endpoints: Endpoint[]): void {
endpoints.forEach((endpoint) => {
router[endpoint.method](endpoint.path, endpoint.handler);
});
}
export default mountEndpoints;