diff --git a/tools/claude-plugin/README.md b/tools/claude-plugin/README.md index f2f7d03b0b7..11a6e109288 100644 --- a/tools/claude-plugin/README.md +++ b/tools/claude-plugin/README.md @@ -1,6 +1,6 @@ # Payload Skill for Claude Code -Claude Code skill providing comprehensive guidance for Payload 3.x development with TypeScript patterns, field configurations, hooks, access control, and API examples. +Claude Code skill providing comprehensive guidance for Payload development with TypeScript patterns, field configurations, hooks, access control, and API examples. ## Installation diff --git a/tools/claude-plugin/skills/payload/SKILL.md b/tools/claude-plugin/skills/payload/SKILL.md index 55b4b5b0144..67e81af1066 100644 --- a/tools/claude-plugin/skills/payload/SKILL.md +++ b/tools/claude-plugin/skills/payload/SKILL.md @@ -1,11 +1,11 @@ --- name: payload -description: Use when working with Payload CMS projects, payload.config.ts, collections, fields, hooks, access control, or Payload API. Provides TypeScript patterns and examples for Payload 3.x development. +description: Use when working with Payload CMS projects (payload.config.ts, collections, fields, hooks, access control, Payload API). Use when debugging validation errors, security issues, relationship queries, transactions, or hook behavior. --- # Payload CMS Application Development -Payload 3.x is a Next.js native CMS with TypeScript-first architecture, providing admin panel, database management, REST/GraphQL APIs, authentication, and file storage. +Payload is a Next.js native CMS with TypeScript-first architecture, providing admin panel, database management, REST/GraphQL APIs, authentication, and file storage. ## Quick Reference @@ -17,6 +17,12 @@ Payload 3.x is a Next.js native CMS with TypeScript-first architecture, providin | Draft/publish workflow | `versions: { drafts: true }` | [COLLECTIONS.md#versioning--drafts](reference/COLLECTIONS.md#versioning--drafts) | | Computed fields | `virtual: true` with afterRead | [FIELDS.md#virtual-fields](reference/FIELDS.md#virtual-fields) | | Conditional fields | `admin.condition` | [FIELDS.md#conditional-fields](reference/FIELDS.md#conditional-fields) | +| Custom field validation | `validate` function | [FIELDS.md#validation](reference/FIELDS.md#validation) | +| Filter relationship list | `filterOptions` on field | [FIELDS.md#relationship](reference/FIELDS.md#relationship) | +| Select specific fields | `select` parameter | [QUERIES.md#field-selection](reference/QUERIES.md#field-selection) | +| Auto-set author/dates | beforeChange hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) | +| Prevent hook loops | `req.context` check | [HOOKS.md#context](reference/HOOKS.md#context) | +| Cascading deletes | beforeDelete hook | [HOOKS.md#collection-hooks](reference/HOOKS.md#collection-hooks) | | Geospatial queries | `point` field with `near`/`within` | [FIELDS.md#point-geolocation](reference/FIELDS.md#point-geolocation) | | Reverse relationships | `join` field type | [FIELDS.md#join-fields](reference/FIELDS.md#join-fields) | | Next.js revalidation | Context control in afterChange | [HOOKS.md#nextjs-revalidation-with-context-control](reference/HOOKS.md#nextjs-revalidation-with-context-control) | @@ -24,13 +30,14 @@ Payload 3.x is a Next.js native CMS with TypeScript-first architecture, providin | Complex queries | AND/OR logic | [QUERIES.md#andor-logic](reference/QUERIES.md#andor-logic) | | Transactions | Pass `req` to operations | [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations) | | Background jobs | Jobs queue with tasks | [ADVANCED.md#jobs-queue](reference/ADVANCED.md#jobs-queue) | -| Custom API routes | Collection/root endpoints | [ADVANCED.md#custom-endpoints](reference/ADVANCED.md#custom-endpoints) | +| Custom API routes | Collection custom endpoints | [ADVANCED.md#custom-endpoints](reference/ADVANCED.md#custom-endpoints) | | Cloud storage | Storage adapter plugins | [ADAPTERS.md#storage-adapters](reference/ADAPTERS.md#storage-adapters) | | Multi-language | `localization` config + `localized: true` | [ADVANCED.md#localization](reference/ADVANCED.md#localization) | | Create plugin | `(options) => (config) => Config` | [PLUGIN-DEVELOPMENT.md#plugin-architecture](reference/PLUGIN-DEVELOPMENT.md#plugin-architecture) | | Plugin package setup | Package structure with SWC | [PLUGIN-DEVELOPMENT.md#plugin-package-structure](reference/PLUGIN-DEVELOPMENT.md#plugin-package-structure) | | Add fields to collection | Map collections, spread fields | [PLUGIN-DEVELOPMENT.md#adding-fields-to-collections](reference/PLUGIN-DEVELOPMENT.md#adding-fields-to-collections) | | Plugin hooks | Preserve existing hooks in array | [PLUGIN-DEVELOPMENT.md#adding-hooks](reference/PLUGIN-DEVELOPMENT.md#adding-hooks) | +| Check field type | Type guard functions | [FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md) | ## Quick Start @@ -138,6 +145,30 @@ export const Posts: CollectionConfig = { For all hook patterns, see [HOOKS.md](reference/HOOKS.md). For access control, see [ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md). +### Access Control with Type Safety + +```ts +import type { Access } from 'payload' +import type { User } from '@/payload-types' + +// Type-safe access control +export const adminOnly: Access = ({ req }) => { + const user = req.user as User + return user?.roles?.includes('admin') || false +} + +// Row-level access control +export const ownPostsOnly: Access = ({ req }) => { + const user = req.user as User + if (!user) return false + if (user.roles?.includes('admin')) return true + + return { + author: { equals: user.id }, + } +} +``` + ### Query Example ```ts @@ -152,10 +183,156 @@ const posts = await payload.find({ limit: 10, sort: '-createdAt', }) + +// Query with populated relationships +const post = await payload.findByID({ + collection: 'posts', + id: '123', + depth: 2, // Populates relationships (default is 2) +}) +// Returns: { author: { id: "user123", name: "John" } } + +// Without depth, relationships return IDs only +const post = await payload.findByID({ + collection: 'posts', + id: '123', + depth: 0, +}) +// Returns: { author: "user123" } ``` For all query operators and REST/GraphQL examples, see [QUERIES.md](reference/QUERIES.md). +### Getting Payload Instance + +```ts +// In API routes (Next.js) +import { getPayload } from 'payload' +import config from '@payload-config' + +export async function GET() { + const payload = await getPayload({ config }) + + const posts = await payload.find({ + collection: 'posts', + }) + + return Response.json(posts) +} + +// In Server Components +import { getPayload } from 'payload' +import config from '@payload-config' + +export default async function Page() { + const payload = await getPayload({ config }) + const { docs } = await payload.find({ collection: 'posts' }) + + return
{docs.map(post =>

{post.title}

)}
+} +``` + +## Security Pitfalls + +### 1. Local API Access Control (CRITICAL) + +**By default, Local API operations bypass ALL access control**, even when passing a user. + +```ts +// ❌ SECURITY BUG: Passes user but ignores their permissions +await payload.find({ + collection: 'posts', + user: someUser, // Access control is BYPASSED! +}) + +// ✅ SECURE: Actually enforces the user's permissions +await payload.find({ + collection: 'posts', + user: someUser, + overrideAccess: false, // REQUIRED for access control +}) +``` + +**When to use each:** + +- `overrideAccess: true` (default) - Server-side operations you trust (cron jobs, system tasks) +- `overrideAccess: false` - When operating on behalf of a user (API routes, webhooks) + +See [QUERIES.md#access-control-in-local-api](reference/QUERIES.md#access-control-in-local-api). + +### 2. Transaction Failures in Hooks + +**Nested operations in hooks without `req` break transaction atomicity.** + +```ts +// ❌ DATA CORRUPTION RISK: Separate transaction +hooks: { + afterChange: [ + async ({ doc, req }) => { + await req.payload.create({ + collection: 'audit-log', + data: { docId: doc.id }, + // Missing req - runs in separate transaction! + }) + }, + ] +} + +// ✅ ATOMIC: Same transaction +hooks: { + afterChange: [ + async ({ doc, req }) => { + await req.payload.create({ + collection: 'audit-log', + data: { docId: doc.id }, + req, // Maintains atomicity + }) + }, + ] +} +``` + +See [ADAPTERS.md#threading-req-through-operations](reference/ADAPTERS.md#threading-req-through-operations). + +### 3. Infinite Hook Loops + +**Hooks triggering operations that trigger the same hooks create infinite loops.** + +```ts +// ❌ INFINITE LOOP +hooks: { + afterChange: [ + async ({ doc, req }) => { + await req.payload.update({ + collection: 'posts', + id: doc.id, + data: { views: doc.views + 1 }, + req, + }) // Triggers afterChange again! + }, + ] +} + +// ✅ SAFE: Use context flag +hooks: { + afterChange: [ + async ({ doc, req, context }) => { + if (context.skipHooks) return + + await req.payload.update({ + collection: 'posts', + id: doc.id, + data: { views: doc.views + 1 }, + context: { skipHooks: true }, + req, + }) + }, + ] +} +``` + +See [HOOKS.md#context](reference/HOOKS.md#context). + ## Project Structure ```txt @@ -196,11 +373,13 @@ import type { Post, User } from '@/payload-types' ## Reference Documentation - **[FIELDS.md](reference/FIELDS.md)** - All field types, validation, admin options +- **[FIELD-TYPE-GUARDS.md](reference/FIELD-TYPE-GUARDS.md)** - Type guards for runtime field type checking and narrowing - **[COLLECTIONS.md](reference/COLLECTIONS.md)** - Collection configs, auth, upload, drafts, live preview - **[HOOKS.md](reference/HOOKS.md)** - Collection hooks, field hooks, context patterns - **[ACCESS-CONTROL.md](reference/ACCESS-CONTROL.md)** - Collection, field, global access control, RBAC, multi-tenant - **[ACCESS-CONTROL-ADVANCED.md](reference/ACCESS-CONTROL-ADVANCED.md)** - Context-aware, time-based, subscription-based access, factory functions, templates - **[QUERIES.md](reference/QUERIES.md)** - Query operators, Local/REST/GraphQL APIs +- **[ENDPOINTS.md](reference/ENDPOINTS.md)** - Custom API endpoints: authentication, helpers, request/response patterns - **[ADAPTERS.md](reference/ADAPTERS.md)** - Database, storage, email adapters, transactions - **[ADVANCED.md](reference/ADVANCED.md)** - Authentication, jobs, endpoints, components, plugins, localization - **[PLUGIN-DEVELOPMENT.md](reference/PLUGIN-DEVELOPMENT.md)** - Plugin architecture, monorepo structure, patterns, best practices diff --git a/tools/claude-plugin/skills/payload/reference/ADVANCED.md b/tools/claude-plugin/skills/payload/reference/ADVANCED.md index c2e2efe109b..c173e849923 100644 --- a/tools/claude-plugin/skills/payload/reference/ADVANCED.md +++ b/tools/claude-plugin/skills/payload/reference/ADVANCED.md @@ -156,6 +156,8 @@ Multi-step jobs that run in sequence: ## Custom Endpoints +Add custom REST API routes to collections, globals, or root config. See [ENDPOINTS.md](ENDPOINTS.md) for detailed patterns, authentication, helpers, and real-world examples. + ### Root Endpoints ```ts diff --git a/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md b/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md new file mode 100644 index 00000000000..99ef908d421 --- /dev/null +++ b/tools/claude-plugin/skills/payload/reference/ENDPOINTS.md @@ -0,0 +1,634 @@ +# Payload Custom API Endpoints Reference + +Custom REST API endpoints extend Payload's auto-generated CRUD operations with custom logic, authentication flows, webhooks, and integrations. + +## Quick Reference + +### Endpoint Configuration + +| Property | Type | Description | +| --------- | ------------------------------------------------- | --------------------------------------------------------------- | +| `path` | `string` | Route path after collection/global slug (e.g., `/:id/tracking`) | +| `method` | `'get' \| 'post' \| 'put' \| 'patch' \| 'delete'` | HTTP method (lowercase) | +| `handler` | `(req: PayloadRequest) => Promise` | Async function returning Web API Response | +| `custom` | `Record` | Extension point for plugins/metadata | + +### Request Context + +| Property | Type | Description | +| ----------------- | ----------------------- | ------------------------------------------------------ | +| `req.user` | `User \| null` | Authenticated user (null if not authenticated) | +| `req.payload` | `Payload` | Payload instance for operations (find, create...) | +| `req.routeParams` | `Record` | Path parameters (e.g., `:id`) | +| `req.url` | `string` | Full request URL | +| `req.method` | `string` | HTTP method | +| `req.headers` | `Headers` | Request headers | +| `req.json()` | `() => Promise` | Parse JSON body | +| `req.text()` | `() => Promise` | Read body as text | +| `req.data` | `any` | Parsed body (after `addDataAndFileToRequest()`) | +| `req.file` | `File` | Uploaded file (after `addDataAndFileToRequest()`) | +| `req.locale` | `string` | Request locale (after `addLocalesToRequestFromData()`) | +| `req.i18n` | `I18n` | i18n instance | +| `req.t` | `TFunction` | Translation function | + +## Common Patterns + +### Authentication Check + +Custom endpoints are **not authenticated by default**. Check `req.user` to enforce authentication. + +```ts +import { APIError } from 'payload' + +export const authenticatedEndpoint = { + path: '/protected', + method: 'get', + handler: async (req) => { + if (!req.user) { + throw new APIError('Unauthorized', 401) + } + + // User is authenticated + return Response.json({ message: 'Access granted' }) + }, +} +``` + +### Using Payload Operations + +Use `req.payload` for database operations with access control and hooks. + +```ts +export const getRelatedPosts = { + path: '/:id/related', + method: 'get', + handler: async (req) => { + const { id } = req.routeParams + + // Find related posts + const posts = await req.payload.find({ + collection: 'posts', + where: { + category: { + equals: id, + }, + }, + limit: 5, + sort: '-createdAt', + }) + + return Response.json(posts) + }, +} +``` + +### Route Parameters + +Access path parameters via `req.routeParams`. + +```ts +export const getTrackingEndpoint = { + path: '/:id/tracking', + method: 'get', + handler: async (req) => { + const orderId = req.routeParams.id + + const tracking = await getTrackingInfo(orderId) + + if (!tracking) { + return Response.json({ error: 'not found' }, { status: 404 }) + } + + return Response.json(tracking) + }, +} +``` + +### Request Body Handling + +**Option 1: Manual JSON parsing** + +```ts +export const createEndpoint = { + path: '/create', + method: 'post', + handler: async (req) => { + const data = await req.json() + + const result = await req.payload.create({ + collection: 'posts', + data, + }) + + return Response.json(result) + }, +} +``` + +**Option 2: Using helper (handles JSON + files)** + +```ts +import { addDataAndFileToRequest } from 'payload' + +export const uploadEndpoint = { + path: '/upload', + method: 'post', + handler: async (req) => { + await addDataAndFileToRequest(req) + + // req.data now contains parsed body + // req.file contains uploaded file (if multipart) + + const result = await req.payload.create({ + collection: 'media', + data: req.data, + file: req.file, + }) + + return Response.json(result) + }, +} +``` + +### CORS Headers + +Use `headersWithCors` helper to apply config CORS settings. + +```ts +import { headersWithCors } from 'payload' + +export const corsEndpoint = { + path: '/public-data', + method: 'get', + handler: async (req) => { + const data = await fetchPublicData() + + return Response.json(data, { + headers: headersWithCors({ + headers: new Headers(), + req, + }), + }) + }, +} +``` + +### Error Handling + +Throw `APIError` with status codes for proper error responses. + +```ts +import { APIError } from 'payload' + +export const validateEndpoint = { + path: '/validate', + method: 'post', + handler: async (req) => { + const data = await req.json() + + if (!data.email) { + throw new APIError('Email is required', 400) + } + + // Validation passed + return Response.json({ valid: true }) + }, +} +``` + +### Query Parameters + +Extract query params from URL. + +```ts +export const searchEndpoint = { + path: '/search', + method: 'get', + handler: async (req) => { + const url = new URL(req.url) + const query = url.searchParams.get('q') + const limit = parseInt(url.searchParams.get('limit') || '10') + + const results = await req.payload.find({ + collection: 'posts', + where: { + title: { + contains: query, + }, + }, + limit, + }) + + return Response.json(results) + }, +} +``` + +## Helper Functions + +### addDataAndFileToRequest + +Parses request body and attaches to `req.data` and `req.file`. + +```ts +import { addDataAndFileToRequest } from 'payload' + +export const endpoint = { + path: '/process', + method: 'post', + handler: async (req) => { + await addDataAndFileToRequest(req) + + // req.data: parsed JSON or form data + // req.file: uploaded file (if multipart) + + console.log(req.data) // { title: 'My Post' } + console.log(req.file) // File object or undefined + }, +} +``` + +**Handles:** + +- JSON bodies (`Content-Type: application/json`) +- Form data (`Content-Type: multipart/form-data`) +- File uploads + +### addLocalesToRequestFromData + +Extracts locale from request data and validates against config. + +```ts +import { addLocalesToRequestFromData } from 'payload' + +export const endpoint = { + path: '/translate', + method: 'post', + handler: async (req) => { + await addLocalesToRequestFromData(req) + + // req.locale: validated locale string + // req.fallbackLocale: fallback locale string + + const result = await req.payload.find({ + collection: 'posts', + locale: req.locale, + }) + + return Response.json(result) + }, +} +``` + +### headersWithCors + +Applies CORS headers from Payload config. + +```ts +import { headersWithCors } from 'payload' + +export const endpoint = { + path: '/data', + method: 'get', + handler: async (req) => { + const data = { message: 'Hello' } + + return Response.json(data, { + headers: headersWithCors({ + headers: new Headers({ + 'Cache-Control': 'public, max-age=3600', + }), + req, + }), + }) + }, +} +``` + +## Real-World Examples + +### Multi-Tenant Login Endpoint + +From `examples/multi-tenant`: + +```ts +import { APIError, generatePayloadCookie, headersWithCors } from 'payload' + +export const externalUsersLogin = { + path: '/login-external', + method: 'post', + handler: async (req) => { + const { email, password, tenant } = await req.json() + + if (!email || !password || !tenant) { + throw new APIError('Missing credentials', 400) + } + + // Find user with tenant constraint + const userQuery = await req.payload.find({ + collection: 'users', + where: { + and: [ + { email: { equals: email } }, + { + or: [{ tenants: { equals: tenant } }, { 'tenants.tenant': { equals: tenant } }], + }, + ], + }, + }) + + if (!userQuery.docs.length) { + throw new APIError('Invalid credentials', 401) + } + + // Authenticate user + const result = await req.payload.login({ + collection: 'users', + data: { email, password }, + }) + + return Response.json(result, { + headers: headersWithCors({ + headers: new Headers({ + 'Set-Cookie': generatePayloadCookie({ + collectionAuthConfig: req.payload.config.collections.find((c) => c.slug === 'users') + .auth, + cookiePrefix: req.payload.config.cookiePrefix, + token: result.token, + }), + }), + req, + }), + }) + }, +} +``` + +### Webhook Handler (Stripe) + +From `packages/plugin-ecommerce`: + +```ts +export const webhookEndpoint = { + path: '/webhooks', + method: 'post', + handler: async (req) => { + const body = await req.text() + const signature = req.headers.get('stripe-signature') + + try { + const event = stripe.webhooks.constructEvent(body, signature, webhookSecret) + + // Process event + switch (event.type) { + case 'payment_intent.succeeded': + await handlePaymentSuccess(req.payload, event.data.object) + break + case 'payment_intent.failed': + await handlePaymentFailure(req.payload, event.data.object) + break + } + + return Response.json({ received: true }) + } catch (err) { + req.payload.logger.error(`Webhook error: ${err.message}`) + return Response.json({ error: err.message }, { status: 400 }) + } + }, +} +``` + +### Data Preview Endpoint + +From `packages/plugin-import-export`: + +```ts +import { addDataAndFileToRequest } from 'payload' + +export const previewEndpoint = { + path: '/preview', + method: 'post', + handler: async (req) => { + if (!req.user) { + throw new APIError('Unauthorized', 401) + } + + await addDataAndFileToRequest(req) + + const { collection, where, limit = 10 } = req.data + + // Validate collection exists + const collectionConfig = req.payload.config.collections.find((c) => c.slug === collection) + if (!collectionConfig) { + throw new APIError('Collection not found', 404) + } + + // Preview data + const results = await req.payload.find({ + collection, + where, + limit, + depth: 0, + }) + + return Response.json({ + docs: results.docs, + totalDocs: results.totalDocs, + fields: collectionConfig.fields, + }) + }, +} +``` + +### Reindex Action Endpoint + +From `packages/plugin-search`: + +```ts +export const reindexEndpoint = (pluginConfig) => ({ + path: '/reindex', + method: 'post', + handler: async (req) => { + if (!req.user) { + throw new APIError('Unauthorized', 401) + } + + const { collection } = req.routeParams + + // Reindex collection + const result = await reindexCollection(req.payload, collection, pluginConfig) + + return Response.json({ + message: `Reindexed ${result.count} documents`, + count: result.count, + }) + }, +}) +``` + +## Endpoint Placement + +### Collection Endpoints + +Mounted at `/api/{collection-slug}/{path}`. + +```ts +import type { CollectionConfig } from 'payload' + +export const Orders: CollectionConfig = { + slug: 'orders', + fields: [ + /* ... */ + ], + endpoints: [ + { + path: '/:id/tracking', + method: 'get', + handler: async (req) => { + // Available at: /api/orders/:id/tracking + const orderId = req.routeParams.id + return Response.json({ orderId }) + }, + }, + ], +} +``` + +### Global Endpoints + +Mounted at `/api/globals/{global-slug}/{path}`. + +```ts +import type { GlobalConfig } from 'payload' + +export const Settings: GlobalConfig = { + slug: 'settings', + fields: [ + /* ... */ + ], + endpoints: [ + { + path: '/clear-cache', + method: 'post', + handler: async (req) => { + // Available at: /api/globals/settings/clear-cache + await clearCache() + return Response.json({ message: 'Cache cleared' }) + }, + }, + ], +} +``` + +## Advanced Patterns + +### Factory Functions + +Create reusable endpoint factories for plugins. + +```ts +export const createWebhookEndpoint = (config) => ({ + path: '/webhook', + method: 'post', + handler: async (req) => { + const signature = req.headers.get('x-webhook-signature') + + if (!verifySignature(signature, config.secret)) { + throw new APIError('Invalid signature', 401) + } + + const data = await req.json() + await processWebhook(req.payload, data, config) + + return Response.json({ received: true }) + }, +}) +``` + +### Conditional Endpoints + +Add endpoints based on config options. + +```ts +export const MyCollection: CollectionConfig = { + slug: 'posts', + fields: [ + /* ... */ + ], + endpoints: [ + // Always included + { + path: '/public', + method: 'get', + handler: async (req) => Response.json({ data: [] }), + }, + // Conditionally included + ...(process.env.ENABLE_ANALYTICS + ? [ + { + path: '/analytics', + method: 'get', + handler: async (req) => Response.json({ analytics: [] }), + }, + ] + : []), + ], +} +``` + +### OpenAPI Documentation + +Use `custom` property for API documentation metadata. + +```ts +export const endpoint = { + path: '/search', + method: 'get', + handler: async (req) => { + // Handler implementation + }, + custom: { + openapi: { + summary: 'Search posts', + parameters: [ + { + name: 'q', + in: 'query', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + 200: { + description: 'Search results', + content: { + 'application/json': { + schema: { type: 'array' }, + }, + }, + }, + }, + }, + }, +} +``` + +## Best Practices + +1. **Always check authentication** - Custom endpoints are not authenticated by default +2. **Use `req.payload` for operations** - Ensures access control and hooks execute +3. **Use helpers for common tasks** - `addDataAndFileToRequest`, `headersWithCors`, etc. +4. **Throw `APIError` for errors** - Provides consistent error responses +5. **Return Web API `Response`** - Use `Response.json()` for consistent responses +6. **Validate input** - Check required fields, validate types +7. **Handle CORS** - Use `headersWithCors` for cross-origin requests +8. **Log errors** - Use `req.payload.logger` for debugging +9. **Document with `custom`** - Add OpenAPI metadata for API docs +10. **Factory pattern for reuse** - Create endpoint factories for plugins + +## Resources + +- REST API Overview: +- Custom Endpoints: +- Access Control: +- Local API: diff --git a/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md b/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md new file mode 100644 index 00000000000..59ec9380e8a --- /dev/null +++ b/tools/claude-plugin/skills/payload/reference/FIELD-TYPE-GUARDS.md @@ -0,0 +1,553 @@ +# Payload Field Type Guards Reference + +Complete reference with detailed examples and patterns. See [FIELDS.md](FIELDS.md#field-type-guards) for quick reference table of all guards. + +## Structural Guards + +### fieldHasSubFields + +Checks if field contains nested fields (group, array, row, or collapsible). + +```ts +import type { Field } from 'payload' +import { fieldHasSubFields } from 'payload' + +function traverseFields(fields: Field[]): void { + fields.forEach((field) => { + if (fieldHasSubFields(field)) { + // Safe to access field.fields + traverseFields(field.fields) + } + }) +} +``` + +**Signature:** + +```ts +fieldHasSubFields( + field: TField +): field is TField & (FieldWithSubFieldsClient | FieldWithSubFields) +``` + +**Common Pattern - Exclude Arrays:** + +```ts +if (fieldHasSubFields(field) && !fieldIsArrayType(field)) { + // Groups, rows, collapsibles only (not arrays) +} +``` + +### fieldIsArrayType + +Checks if field type is `'array'`. + +```ts +import { fieldIsArrayType } from 'payload' + +if (fieldIsArrayType(field)) { + // field.type === 'array' + console.log(`Min rows: ${field.minRows}`) + console.log(`Max rows: ${field.maxRows}`) +} +``` + +**Signature:** + +```ts +fieldIsArrayType( + field: TField +): field is TField & (ArrayFieldClient | ArrayField) +``` + +### fieldIsBlockType + +Checks if field type is `'blocks'`. + +```ts +import { fieldIsBlockType } from 'payload' + +if (fieldIsBlockType(field)) { + // field.type === 'blocks' + field.blocks.forEach((block) => { + console.log(`Block: ${block.slug}`) + }) +} +``` + +**Signature:** + +```ts +fieldIsBlockType( + field: TField +): field is TField & (BlocksFieldClient | BlocksField) +``` + +**Common Pattern - Distinguish Containers:** + +```ts +if (fieldIsArrayType(field)) { + // Handle array rows +} else if (fieldIsBlockType(field)) { + // Handle block types +} +``` + +### fieldIsGroupType + +Checks if field type is `'group'`. + +```ts +import { fieldIsGroupType } from 'payload' + +if (fieldIsGroupType(field)) { + // field.type === 'group' + console.log(`Interface: ${field.interfaceName}`) +} +``` + +**Signature:** + +```ts +fieldIsGroupType( + field: TField +): field is TField & (GroupFieldClient | GroupField) +``` + +## Capability Guards + +### fieldSupportsMany + +Checks if field can have multiple values (select, relationship, or upload with `hasMany`). + +```ts +import { fieldSupportsMany } from 'payload' + +if (fieldSupportsMany(field)) { + // field.type is 'select' | 'relationship' | 'upload' + // Safe to check field.hasMany + if (field.hasMany) { + console.log('Field accepts multiple values') + } +} +``` + +**Signature:** + +```ts +fieldSupportsMany( + field: TField +): field is TField & (FieldWithManyClient | FieldWithMany) +``` + +### fieldHasMaxDepth + +Checks if field is relationship/upload/join with numeric `maxDepth` property. + +```ts +import { fieldHasMaxDepth } from 'payload' + +if (fieldHasMaxDepth(field)) { + // field.type is 'upload' | 'relationship' | 'join' + // AND field.maxDepth is number + const remainingDepth = field.maxDepth - currentDepth +} +``` + +**Signature:** + +```ts +fieldHasMaxDepth( + field: TField +): field is TField & (FieldWithMaxDepthClient | FieldWithMaxDepth) +``` + +### fieldShouldBeLocalized + +Checks if field needs localization handling (accounts for parent localization). + +```ts +import { fieldShouldBeLocalized } from 'payload' + +function processField(field: Field, parentIsLocalized: boolean) { + if (fieldShouldBeLocalized({ field, parentIsLocalized })) { + // Create locale-specific table or index + } +} +``` + +**Signature:** + +```ts +fieldShouldBeLocalized({ + field, + parentIsLocalized, +}: { + field: ClientField | ClientTab | Field | Tab + parentIsLocalized: boolean +}): boolean +``` + +```ts +// Accounts for parent localization +if (fieldShouldBeLocalized({ field, parentIsLocalized: false })) { + /* ... */ +} +``` + +### fieldIsVirtual + +Checks if field is virtual (computed or virtual relationship). + +```ts +import { fieldIsVirtual } from 'payload' + +if (fieldIsVirtual(field)) { + // field.virtual is truthy + if (typeof field.virtual === 'string') { + // Virtual relationship path + console.log(`Virtual path: ${field.virtual}`) + } else { + // Computed virtual field (uses hooks) + } +} +``` + +**Signature:** + +```ts +fieldIsVirtual(field: Field | Tab): boolean +``` + +## Data Guards + +### fieldAffectsData + +**Most commonly used guard.** Checks if field stores data (has name and is not UI-only). + +```ts +import { fieldAffectsData } from 'payload' + +function generateSchema(fields: Field[]) { + fields.forEach((field) => { + if (fieldAffectsData(field)) { + // Safe to access field.name + schema[field.name] = getFieldType(field) + } + }) +} +``` + +**Signature:** + +```ts +fieldAffectsData( + field: TField +): field is TField & (FieldAffectingDataClient | FieldAffectingData) +``` + +**Pattern - Data Fields Only:** + +```ts +const dataFields = fields.filter(fieldAffectsData) +``` + +### fieldIsPresentationalOnly + +Checks if field is UI-only (type `'ui'`). + +```ts +import { fieldIsPresentationalOnly } from 'payload' + +if (fieldIsPresentationalOnly(field)) { + // field.type === 'ui' + // Skip in data operations, GraphQL schema, etc. + return +} +``` + +**Signature:** + +```ts +fieldIsPresentationalOnly( + field: TField +): field is TField & (UIFieldClient | UIField) +``` + +### fieldIsID + +Checks if field name is exactly `'id'`. + +```ts +import { fieldIsID } from 'payload' + +if (fieldIsID(field)) { + // field.name === 'id' + // Special handling for ID field +} +``` + +**Signature:** + +```ts +fieldIsID( + field: TField +): field is { name: 'id' } & TField +``` + +### fieldIsHiddenOrDisabled + +Checks if field is hidden or admin-disabled. + +```ts +import { fieldIsHiddenOrDisabled } from 'payload' + +const visibleFields = fields.filter((field) => !fieldIsHiddenOrDisabled(field)) +``` + +**Signature:** + +```ts +fieldIsHiddenOrDisabled( + field: TField +): field is { admin: { hidden: true } } & TField +``` + +## Layout Guards + +### fieldIsSidebar + +Checks if field is positioned in sidebar. + +```ts +import { fieldIsSidebar } from 'payload' + +const [mainFields, sidebarFields] = fields.reduce( + ([main, sidebar], field) => { + if (fieldIsSidebar(field)) { + return [main, [...sidebar, field]] + } + return [[...main, field], sidebar] + }, + [[], []], +) +``` + +**Signature:** + +```ts +fieldIsSidebar( + field: TField +): field is { admin: { position: 'sidebar' } } & TField +``` + +## Tab & Group Guards + +### tabHasName + +Checks if tab is named (stores data under tab name). + +```ts +import { tabHasName } from 'payload' + +tabs.forEach((tab) => { + if (tabHasName(tab)) { + // tab.name exists + dataPath.push(tab.name) + } + // Process tab.fields +}) +``` + +**Signature:** + +```ts +tabHasName( + tab: TField +): tab is NamedTab & TField +``` + +### groupHasName + +Checks if group is named (stores data under group name). + +```ts +import { groupHasName } from 'payload' + +if (groupHasName(group)) { + // group.name exists + return data[group.name] +} +``` + +**Signature:** + +```ts +groupHasName(group: Partial): group is NamedGroupFieldClient +``` + +## Option & Value Guards + +### optionIsObject + +Checks if option is object format `{label, value}` vs string. + +```ts +import { optionIsObject } from 'payload' + +field.options.forEach((option) => { + if (optionIsObject(option)) { + console.log(`${option.label}: ${option.value}`) + } else { + console.log(option) // string value + } +}) +``` + +**Signature:** + +```ts +optionIsObject(option: Option): option is OptionObject +``` + +### optionsAreObjects + +Checks if entire options array contains objects. + +```ts +import { optionsAreObjects } from 'payload' + +if (optionsAreObjects(field.options)) { + // All options are OptionObject[] + const labels = field.options.map((opt) => opt.label) +} +``` + +**Signature:** + +```ts +optionsAreObjects(options: Option[]): options is OptionObject[] +``` + +### optionIsValue + +Checks if option is string value (not object). + +```ts +import { optionIsValue } from 'payload' + +if (optionIsValue(option)) { + // option is string + const value = option +} +``` + +**Signature:** + +```ts +optionIsValue(option: Option): option is string +``` + +### valueIsValueWithRelation + +Checks if relationship value is polymorphic format `{relationTo, value}`. + +```ts +import { valueIsValueWithRelation } from 'payload' + +if (valueIsValueWithRelation(fieldValue)) { + // fieldValue.relationTo exists + // fieldValue.value exists + console.log(`Related to ${fieldValue.relationTo}: ${fieldValue.value}`) +} +``` + +**Signature:** + +```ts +valueIsValueWithRelation(value: unknown): value is ValueWithRelation +``` + +## Common Patterns + +### Recursive Field Traversal + +```ts +import { fieldAffectsData, fieldHasSubFields } from 'payload' + +function traverseFields(fields: Field[], callback: (field: Field) => void) { + fields.forEach((field) => { + if (fieldAffectsData(field)) { + callback(field) + } + + if (fieldHasSubFields(field)) { + traverseFields(field.fields, callback) + } + }) +} +``` + +### Filter Data-Bearing Fields + +```ts +import { fieldAffectsData, fieldIsPresentationalOnly, fieldIsHiddenOrDisabled } from 'payload' + +const dataFields = fields.filter( + (field) => + fieldAffectsData(field) && !fieldIsPresentationalOnly(field) && !fieldIsHiddenOrDisabled(field), +) +``` + +### Container Type Switching + +```ts +import { fieldIsArrayType, fieldIsBlockType, fieldHasSubFields } from 'payload' + +if (fieldIsArrayType(field)) { + // Handle array-specific logic +} else if (fieldIsBlockType(field)) { + // Handle blocks-specific logic +} else if (fieldHasSubFields(field)) { + // Handle group/row/collapsible +} +``` + +### Safe Property Access + +```ts +import { fieldSupportsMany, fieldHasMaxDepth } from 'payload' + +// Without guard - TypeScript error +// if (field.hasMany) { /* ... */ } + +// With guard - safe access +if (fieldSupportsMany(field) && field.hasMany) { + console.log('Multiple values supported') +} + +if (fieldHasMaxDepth(field)) { + const depth = field.maxDepth // TypeScript knows this is number +} +``` + +## Type Preservation + +All guards preserve the original type constraint: + +```ts +import type { ClientField, Field } from 'payload' +import { fieldHasSubFields } from 'payload' + +function processServerField(field: Field) { + if (fieldHasSubFields(field)) { + // field is Field & FieldWithSubFields (not ClientField) + } +} + +function processClientField(field: ClientField) { + if (fieldHasSubFields(field)) { + // field is ClientField & FieldWithSubFieldsClient + } +} +``` diff --git a/tools/claude-plugin/skills/payload/reference/FIELDS.md b/tools/claude-plugin/skills/payload/reference/FIELDS.md index bff3d44d3fa..587682bd61d 100644 --- a/tools/claude-plugin/skills/payload/reference/FIELDS.md +++ b/tools/claude-plugin/skills/payload/reference/FIELDS.md @@ -698,3 +698,47 @@ const ctaButton = link({ }, }) ``` + +## Field Type Guards + +Type guards for runtime field type checking and safe type narrowing. + +| Type Guard | Checks For | Use When | +| --------------------------- | ----------------------------------------------------------- | ---------------------------------------- | +| `fieldAffectsData` | Field stores data (has name, not UI-only) | Need to access field data or name | +| `fieldHasSubFields` | Field contains nested fields (group/array/row/collapsible) | Need to recursively traverse fields | +| `fieldIsArrayType` | Field is array type | Distinguish arrays from other containers | +| `fieldIsBlockType` | Field is blocks type | Handle blocks-specific logic | +| `fieldIsGroupType` | Field is group type | Handle group-specific logic | +| `fieldSupportsMany` | Field can have multiple values (select/relationship/upload) | Check for `hasMany` support | +| `fieldHasMaxDepth` | Field supports population depth control | Control relationship/upload/join depth | +| `fieldIsPresentationalOnly` | Field is UI-only (no data storage) | Exclude from data operations | +| `fieldIsSidebar` | Field positioned in sidebar | Separate sidebar rendering | +| `fieldIsID` | Field name is 'id' | Special ID field handling | +| `fieldIsHiddenOrDisabled` | Field is hidden or disabled | Filter from UI operations | +| `fieldShouldBeLocalized` | Field needs localization handling | Proper locale table checks | +| `fieldIsVirtual` | Field is virtual (computed/no DB column) | Skip in database transforms | +| `tabHasName` | Tab is named (stores data) | Distinguish named vs unnamed tabs | +| `groupHasName` | Group is named (stores data) | Distinguish named vs unnamed groups | +| `optionIsObject` | Option is `{label, value}` format | Access option properties safely | +| `optionsAreObjects` | All options are objects | Batch option processing | +| `optionIsValue` | Option is string value | Handle string options | +| `valueIsValueWithRelation` | Value is polymorphic relationship | Handle polymorphic relationships | + +```ts +import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload' + +function processField(field: Field) { + if (fieldAffectsData(field)) { + // Safe to access field.name + console.log(field.name) + } + + if (fieldHasSubFields(field)) { + // Safe to access field.fields + field.fields.forEach(processField) + } +} +``` + +See [FIELD-TYPE-GUARDS.md](FIELD-TYPE-GUARDS.md) for detailed usage patterns.