From 92a5f075b698239903aed1269fbf05c38709e33a Mon Sep 17 00:00:00 2001 From: Sasha <64744993+r1tsuu@users.noreply.github.com> Date: Tue, 30 Sep 2025 00:01:01 +0300 Subject: [PATCH] feat: add Payload SDK package (#9463) Adds Payload SDK package, which can be used to query Payload REST API in a fully type safe way. Has support for all necessary operations, including auth, type safe `select`, `populate`, `joins` properties and simplified file uploading. Its interface is _very_ similar to the Local API, can't even notice the difference: Example: ```ts import { PayloadSDK } from '@payloadcms/sdk' import type { Config } from './payload-types' // Pass your config from generated types as generic const sdk = new PayloadSDK({ baseURL: 'https://example.com/api', }) // Find operation const posts = await sdk.find({ collection: 'posts', draft: true, limit: 10, locale: 'en', page: 1, where: { _status: { equals: 'published' } }, }) // Find by ID operation const posts = await sdk.findByID({ id, collection: 'posts', draft: true, locale: 'en', }) // Auth login operation const result = await sdk.login({ collection: 'users', data: { email: 'dev@payloadcms.com', password: '12345', }, }) // Create operation const result = await sdk.create({ collection: 'posts', data: { text: 'text' }, }) // Create operation with a file // `file` can be either a Blob | File object or a string URL const result = await sdk.create({ collection: 'media', file, data: {} }) // Count operation const result = await sdk.count({ collection: 'posts', where: { id: { equals: post.id } } }) // Update (by ID) operation const result = await sdk.update({ collection: 'posts', id: post.id, data: { text: 'updated-text', }, }) // Update (bulk) operation const result = await sdk.update({ collection: 'posts', where: { id: { equals: post.id, }, }, data: { text: 'updated-text-bulk' }, }) // Delete (by ID) operation const result = await sdk.delete({ id: post.id, collection: 'posts' }) // Delete (bulk) operation const result = await sdk.delete({ where: { id: { equals: post.id } }, collection: 'posts' }) // Find Global operation const result = await sdk.findGlobal({ slug: 'global' }) // Update Global operation const result = await sdk.updateGlobal({ slug: 'global', data: { text: 'some-updated-global' } }) // Auth Login operation const result = await sdk.login({ collection: 'users', data: { email: 'dev@payloadcms.com', password: '123456' }, }) // Auth Me operation const result = await sdk.me( { collection: 'users' }, { headers: { Authorization: `JWT ${user.token}`, }, }, ) // Auth Refresh Token operation const result = await sdk.refreshToken( { collection: 'users' }, { headers: { Authorization: `JWT ${user.token}` } }, ) // Auth Forgot Password operation const result = await sdk.forgotPassword({ collection: 'users', data: { email: user.email }, }) // Auth Reset Password operation const result = await sdk.resetPassword({ collection: 'users', data: { password: '1234567', token: resetPasswordToken }, }) // Find Versions operation const result = await sdk.findVersions({ collection: 'posts', where: { parent: { equals: post.id } }, }) // Find Version by ID operation const result = await sdk.findVersionByID({ collection: 'posts', id: version.id }) // Restore Version operation const result = await sdk.restoreVersion({ collection: 'posts', id, }) // Find Global Versions operation const result = await sdk.findGlobalVersions({ slug: 'global', }) // Find Global Version by ID operation const result = await sdk.findGlobalVersionByID({ id: version.id, slug: 'global' }) // Restore Global Version operation const result = await sdk.restoreGlobalVersion({ slug: 'global', id }) ``` Every operation has optional 3rd parameter which is used to add additional data to the RequestInit object (like headers): ```ts await sdk.me({ collection: "users" }, { // RequestInit object headers: { Authorization: `JWT ${token}` } }) ``` To query custom endpoints, you can use the `request` method, which is used internally for all other methods: ```ts await sdk.request({ method: 'POST', path: '/send-data', json: { id: 1, }, }) ``` Custom `fetch` implementation and `baseInit` for shared `RequestInit` properties: ```ts const sdk = new PayloadSDK({ baseInit: { credentials: 'include' }, baseURL: 'https://example.com/api', fetch: async (url, init) => { console.log('before req') const response = await fetch(url, init) console.log('after req') return response }, }) ``` --- .github/workflows/pr-title.yml | 1 + docs/rest-api/overview.mdx | 274 +++++++++++++ package.json | 1 + packages/sdk/.prettierignore | 10 + packages/sdk/.swcrc | 24 ++ packages/sdk/eslint.config.js | 18 + packages/sdk/license.md | 22 ++ packages/sdk/package.json | 63 +++ packages/sdk/src/auth/forgotPassword.ts | 31 ++ packages/sdk/src/auth/login.ts | 35 ++ packages/sdk/src/auth/me.ts | 30 ++ packages/sdk/src/auth/refreshToken.ts | 32 ++ packages/sdk/src/auth/resetPassword.ts | 40 ++ packages/sdk/src/auth/verifyEmail.ts | 27 ++ packages/sdk/src/collections/count.ts | 34 ++ packages/sdk/src/collections/create.ts | 84 ++++ packages/sdk/src/collections/delete.ts | 102 +++++ packages/sdk/src/collections/find.ts | 105 +++++ packages/sdk/src/collections/findByID.ts | 94 +++++ .../sdk/src/collections/findVersionByID.ts | 95 +++++ packages/sdk/src/collections/findVersions.ts | 97 +++++ .../sdk/src/collections/restoreVersion.ts | 60 +++ packages/sdk/src/collections/update.ts | 128 ++++++ packages/sdk/src/globals/findOne.ts | 65 +++ packages/sdk/src/globals/findVersionByID.ts | 83 ++++ packages/sdk/src/globals/findVersions.ts | 85 ++++ packages/sdk/src/globals/restoreVersion.ts | 61 +++ packages/sdk/src/globals/update.ts | 78 ++++ packages/sdk/src/index.ts | 374 ++++++++++++++++++ packages/sdk/src/types.ts | 172 ++++++++ .../sdk/src/utilities/buildSearchParams.ts | 72 ++++ .../src/utilities/resolveFileFromOptions.ts | 11 + packages/sdk/tsconfig.json | 25 ++ pnpm-lock.yaml | 19 + test/helpers/getSDK.ts | 43 ++ test/helpers/initPayloadInt.ts | 14 +- test/package.json | 1 + test/sdk/.gitignore | 1 + test/sdk/collections/Posts.ts | 40 ++ test/sdk/collections/Users.ts | 14 + test/sdk/config.ts | 51 +++ test/sdk/eslint.config.js | 19 + test/sdk/image.jpg | Bin 0 -> 86124 bytes test/sdk/int.spec.ts | 309 +++++++++++++++ test/sdk/payload-types.ts | 291 ++++++++++++++ test/sdk/shared.ts | 1 + test/sdk/tsconfig.eslint.json | 13 + test/sdk/tsconfig.json | 3 + test/setupProd.ts | 1 + tsconfig.json | 3 + 50 files changed, 3253 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/.prettierignore create mode 100644 packages/sdk/.swcrc create mode 100644 packages/sdk/eslint.config.js create mode 100644 packages/sdk/license.md create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/auth/forgotPassword.ts create mode 100644 packages/sdk/src/auth/login.ts create mode 100644 packages/sdk/src/auth/me.ts create mode 100644 packages/sdk/src/auth/refreshToken.ts create mode 100644 packages/sdk/src/auth/resetPassword.ts create mode 100644 packages/sdk/src/auth/verifyEmail.ts create mode 100644 packages/sdk/src/collections/count.ts create mode 100644 packages/sdk/src/collections/create.ts create mode 100644 packages/sdk/src/collections/delete.ts create mode 100644 packages/sdk/src/collections/find.ts create mode 100644 packages/sdk/src/collections/findByID.ts create mode 100644 packages/sdk/src/collections/findVersionByID.ts create mode 100644 packages/sdk/src/collections/findVersions.ts create mode 100644 packages/sdk/src/collections/restoreVersion.ts create mode 100644 packages/sdk/src/collections/update.ts create mode 100644 packages/sdk/src/globals/findOne.ts create mode 100644 packages/sdk/src/globals/findVersionByID.ts create mode 100644 packages/sdk/src/globals/findVersions.ts create mode 100644 packages/sdk/src/globals/restoreVersion.ts create mode 100644 packages/sdk/src/globals/update.ts create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/src/types.ts create mode 100644 packages/sdk/src/utilities/buildSearchParams.ts create mode 100644 packages/sdk/src/utilities/resolveFileFromOptions.ts create mode 100644 packages/sdk/tsconfig.json create mode 100644 test/helpers/getSDK.ts create mode 100644 test/sdk/.gitignore create mode 100644 test/sdk/collections/Posts.ts create mode 100644 test/sdk/collections/Users.ts create mode 100644 test/sdk/config.ts create mode 100644 test/sdk/eslint.config.js create mode 100644 test/sdk/image.jpg create mode 100644 test/sdk/int.spec.ts create mode 100644 test/sdk/payload-types.ts create mode 100644 test/sdk/shared.ts create mode 100644 test/sdk/tsconfig.eslint.json create mode 100644 test/sdk/tsconfig.json diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index de0ffd53d..3f3607865 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -64,6 +64,7 @@ jobs: richtext-\* richtext-lexical richtext-slate + sdk storage-\* storage-azure storage-gcs diff --git a/docs/rest-api/overview.mdx b/docs/rest-api/overview.mdx index 4fa9c4f6a..53ae5b10c 100644 --- a/docs/rest-api/overview.mdx +++ b/docs/rest-api/overview.mdx @@ -14,6 +14,8 @@ keywords: rest, api, documentation, Content Management System, cms, headless, ja The REST API is a fully functional HTTP client that allows you to interact with your Documents in a RESTful manner. It supports all CRUD operations and is equipped with automatic pagination, depth, and sorting. All Payload API routes are mounted and prefixed to your config's `routes.api` URL segment (default: `/api`). +To enhance DX, you can use [Payload SDK](#payload-rest-api-sdk) to query your REST API. + **REST query parameters:** - [depth](../queries/depth) - automatically populates relationships and uploads @@ -798,3 +800,275 @@ const res = await fetch(`${api}/${collectionSlug}/${id}`, { ``` This can be more efficient for large JSON payloads, as you avoid converting data to and from query strings. However, only certain endpoints support this. Supported endpoints will read the parsed body under a `data` property, instead of reading from query parameters as with standard GET requests. + +## Payload REST API SDK + +The best, fully type-safe way to query Payload REST API is to use the SDK package, which can be installed with: + +```bash +pnpm add @payloadcms/sdk +``` + +Its usage is very similar to [the Local API](../local-api/overview). + + + **Note:** The SDK package is currently in beta and may be subject to change in + minor versions updates prior to being stable. + + +Example: + +```ts +import { PayloadSDK } from '@payloadcms/sdk' +import type { Config } from './payload-types' + +// Pass your config from generated types as generic +const sdk = new PayloadSDK({ + baseURL: 'https://example.com/api', +}) + +// Find operation +const posts = await sdk.find({ + collection: 'posts', + draft: true, + limit: 10, + locale: 'en', + page: 1, + where: { _status: { equals: 'published' } }, +}) + +// Find by ID operation +const posts = await sdk.findByID({ + id, + collection: 'posts', + draft: true, + locale: 'en', +}) + +// Auth login operation +const result = await sdk.login({ + collection: 'users', + data: { + email: 'dev@payloadcms.com', + password: '12345', + }, +}) + +// Create operation +const result = await sdk.create({ + collection: 'posts', + data: { text: 'text' }, +}) + +// Create operation with a file +// `file` can be either a Blob | File object or a string URL +const result = await sdk.create({ collection: 'media', file, data: {} }) + +// Count operation +const result = await sdk.count({ + collection: 'posts', + where: { id: { equals: post.id } }, +}) + +// Update (by ID) operation +const result = await sdk.update({ + collection: 'posts', + id: post.id, + data: { + text: 'updated-text', + }, +}) + +// Update (bulk) operation +const result = await sdk.update({ + collection: 'posts', + where: { + id: { + equals: post.id, + }, + }, + data: { text: 'updated-text-bulk' }, +}) + +// Delete (by ID) operation +const result = await sdk.delete({ id: post.id, collection: 'posts' }) + +// Delete (bulk) operation +const result = await sdk.delete({ + where: { id: { equals: post.id } }, + collection: 'posts', +}) + +// Find Global operation +const result = await sdk.findGlobal({ slug: 'global' }) + +// Update Global operation +const result = await sdk.updateGlobal({ + slug: 'global', + data: { text: 'some-updated-global' }, +}) + +// Auth Login operation +const result = await sdk.login({ + collection: 'users', + data: { email: 'dev@payloadcms.com', password: '123456' }, +}) + +// Auth Me operation +const result = await sdk.me( + { collection: 'users' }, + { + headers: { + Authorization: `JWT ${user.token}`, + }, + }, +) + +// Auth Refresh Token operation +const result = await sdk.refreshToken( + { collection: 'users' }, + { headers: { Authorization: `JWT ${user.token}` } }, +) + +// Auth Forgot Password operation +const result = await sdk.forgotPassword({ + collection: 'users', + data: { email: user.email }, +}) + +// Auth Reset Password operation +const result = await sdk.resetPassword({ + collection: 'users', + data: { password: '1234567', token: resetPasswordToken }, +}) + +// Find Versions operation +const result = await sdk.findVersions({ + collection: 'posts', + where: { parent: { equals: post.id } }, +}) + +// Find Version by ID operation +const result = await sdk.findVersionByID({ + collection: 'posts', + id: version.id, +}) + +// Restore Version operation +const result = await sdk.restoreVersion({ + collection: 'posts', + id, +}) + +// Find Global Versions operation +const result = await sdk.findGlobalVersions({ + slug: 'global', +}) + +// Find Global Version by ID operation +const result = await sdk.findGlobalVersionByID({ + id: version.id, + slug: 'global', +}) + +// Restore Global Version operation +const result = await sdk.restoreGlobalVersion({ + slug: 'global', + id, +}) +``` + +Every operation has optional 3rd parameter which is used to add additional data to the RequestInit object (like headers): + +```ts +await sdk.me( + { + collection: 'users', + }, + { + // RequestInit object + headers: { + Authorization: `JWT ${token}`, + }, + }, +) +``` + +To query custom endpoints, you can use the `request` method, which is used internally for all other methods: + +```ts +await sdk.request({ + method: 'POST', + path: '/send-data', + json: { + id: 1, + }, +}) +``` + +Custom `fetch` implementation and `baseInit` for shared `RequestInit` properties: + +```ts +const sdk = new PayloadSDK({ + baseInit: { credentials: 'include' }, + baseURL: 'https://example.com/api', + fetch: async (url, init) => { + console.log('before req') + const response = await fetch(url, init) + console.log('after req') + return response + }, +}) +``` + +Example of a custom `fetch` implementation for testing the REST API without needing to spin up a next development server: + +```ts +import type { GeneratedTypes, SanitizedConfig } from 'payload' + +import config from '@payload-config' +import { + REST_DELETE, + REST_GET, + REST_PATCH, + REST_POST, + REST_PUT, +} from '@payloadcms/next/routes' +import { PayloadSDK } from '@payloadcms/sdk' + +export type TypedPayloadSDK = PayloadSDK + +const api = { + GET: REST_GET(config), + POST: REST_POST(config), + PATCH: REST_PATCH(config), + DELETE: REST_DELETE(config), + PUT: REST_PUT(config), +} + +const awaitedConfig = await config + +export const sdk = new PayloadSDK({ + baseURL: ``, + fetch: (path: string, init: RequestInit) => { + const [slugs, search] = path.slice(1).split('?') + const url = `${awaitedConfig.serverURL || 'http://localhost:3000'}${awaitedConfig.routes.api}/${slugs}${search ? `?${search}` : ''}` + + if (init.body instanceof FormData) { + const file = init.body.get('file') as Blob + if (file && init.headers instanceof Headers) { + init.headers.set('Content-Length', file.size.toString()) + } + } + const request = new Request(url, init) + + const params = { + params: Promise.resolve({ + slug: slugs.split('/'), + }), + } + + return api[init.method.toUpperCase()](request, params) + }, +}) +``` diff --git a/package.json b/package.json index 3d8919531..ef29bf56a 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "build:releaser": "turbo build --filter \"@tools/releaser\"", "build:richtext-lexical": "turbo build --filter \"@payloadcms/richtext-lexical\"", "build:richtext-slate": "turbo build --filter \"@payloadcms/richtext-slate\"", + "build:sdk": "turbo build --filter \"@payloadcms/sdk\"", "build:storage-azure": "turbo build --filter \"@payloadcms/storage-azure\"", "build:storage-gcs": "turbo build --filter \"@payloadcms/storage-gcs\"", "build:storage-s3": "turbo build --filter \"@payloadcms/storage-s3\"", diff --git a/packages/sdk/.prettierignore b/packages/sdk/.prettierignore new file mode 100644 index 000000000..247f3f12d --- /dev/null +++ b/packages/sdk/.prettierignore @@ -0,0 +1,10 @@ +.tmp +**/.git +**/.hg +**/.pnp.* +**/.svn +**/.yarn/** +**/build +**/dist/** +**/node_modules +**/temp diff --git a/packages/sdk/.swcrc b/packages/sdk/.swcrc new file mode 100644 index 000000000..b4fb882ca --- /dev/null +++ b/packages/sdk/.swcrc @@ -0,0 +1,24 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "sourceMaps": true, + "jsc": { + "target": "esnext", + "parser": { + "syntax": "typescript", + "tsx": true, + "dts": true + }, + "transform": { + "react": { + "runtime": "automatic", + "pragmaFrag": "React.Fragment", + "throwIfNamespace": true, + "development": false, + "useBuiltins": true + } + } + }, + "module": { + "type": "es6" + } +} diff --git a/packages/sdk/eslint.config.js b/packages/sdk/eslint.config.js new file mode 100644 index 000000000..f9d341be5 --- /dev/null +++ b/packages/sdk/eslint.config.js @@ -0,0 +1,18 @@ +import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js' + +/** @typedef {import('eslint').Linter.Config} Config */ + +/** @type {Config[]} */ +export const index = [ + ...rootEslintConfig, + { + languageOptions: { + parserOptions: { + ...rootParserOptions, + tsconfigRootDir: import.meta.dirname, + }, + }, + }, +] + +export default index diff --git a/packages/sdk/license.md b/packages/sdk/license.md new file mode 100644 index 000000000..b31a68cbd --- /dev/null +++ b/packages/sdk/license.md @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2018-2024 Payload CMS, Inc. + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 000000000..bae93ebe2 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,63 @@ +{ + "name": "@payloadcms/sdk", + "version": "3.1.0", + "description": "The official Payload REST API SDK", + "homepage": "https://payloadcms.com", + "repository": { + "type": "git", + "url": "https://github.com/payloadcms/payload.git", + "directory": "packages/sdk" + }, + "license": "MIT", + "author": "Payload (https://payloadcms.com)", + "maintainers": [ + { + "name": "Payload", + "email": "info@payloadcms.com", + "url": "https://payloadcms.com" + } + ], + "type": "module", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "pnpm copyfiles && pnpm build:types && pnpm build:swc", + "build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths", + "build:types": "tsc --emitDeclarationOnly --outDir dist", + "clean": "rimraf {dist,*.tsbuildinfo}", + "copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "prepublishOnly": "pnpm clean && pnpm turbo build" + }, + "dependencies": { + "payload": "workspace:*", + "qs-esm": "7.0.2", + "ts-essentials": "10.0.3" + }, + "devDependencies": { + "@payloadcms/eslint-config": "workspace:*" + }, + "publishConfig": { + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "registry": "https://registry.npmjs.org/", + "types": "./dist/index.d.ts" + } +} diff --git a/packages/sdk/src/auth/forgotPassword.ts b/packages/sdk/src/auth/forgotPassword.ts new file mode 100644 index 000000000..1273b8789 --- /dev/null +++ b/packages/sdk/src/auth/forgotPassword.ts @@ -0,0 +1,31 @@ +import type { PayloadSDK } from '../index.js' +import type { AuthCollectionSlug, PayloadGeneratedTypes, TypedAuth } from '../types.js' + +export type ForgotPasswordOptions< + T extends PayloadGeneratedTypes, + TSlug extends AuthCollectionSlug, +> = { + collection: TSlug + data: { + disableEmail?: boolean + expiration?: number + } & Omit[TSlug]['forgotPassword'], 'password'> +} + +export async function forgotPassword< + T extends PayloadGeneratedTypes, + TSlug extends AuthCollectionSlug, +>( + sdk: PayloadSDK, + options: ForgotPasswordOptions, + init?: RequestInit, +): Promise<{ message: string }> { + const response = await sdk.request({ + init, + json: options.data, + method: 'POST', + path: `/${options.collection}/forgot-password`, + }) + + return response.json() +} diff --git a/packages/sdk/src/auth/login.ts b/packages/sdk/src/auth/login.ts new file mode 100644 index 000000000..aafcaa892 --- /dev/null +++ b/packages/sdk/src/auth/login.ts @@ -0,0 +1,35 @@ +import type { PayloadSDK } from '../index.js' +import type { + AuthCollectionSlug, + DataFromCollectionSlug, + PayloadGeneratedTypes, + TypedAuth, +} from '../types.js' + +export type LoginOptions> = { + collection: TSlug + data: TypedAuth[TSlug]['login'] +} + +export type LoginResult> = { + exp?: number + message: string + token?: string + // @ts-expect-error auth collection and user collection + user: DataFromCollectionSlug +} + +export async function login>( + sdk: PayloadSDK, + options: LoginOptions, + init?: RequestInit, +): Promise> { + const response = await sdk.request({ + init, + json: options.data, + method: 'POST', + path: `/${options.collection}/login`, + }) + + return response.json() +} diff --git a/packages/sdk/src/auth/me.ts b/packages/sdk/src/auth/me.ts new file mode 100644 index 000000000..bf1635489 --- /dev/null +++ b/packages/sdk/src/auth/me.ts @@ -0,0 +1,30 @@ +import type { PayloadSDK } from '../index.js' +import type { AuthCollectionSlug, DataFromCollectionSlug, PayloadGeneratedTypes } from '../types.js' + +export type MeOptions> = { + collection: TSlug +} + +export type MeResult> = { + collection?: TSlug + exp?: number + message: string + strategy?: string + token?: string + // @ts-expect-error auth collection and user collection + user: DataFromCollectionSlug +} + +export async function me>( + sdk: PayloadSDK, + options: MeOptions, + init?: RequestInit, +): Promise> { + const response = await sdk.request({ + init, + method: 'GET', + path: `/${options.collection}/me`, + }) + + return response.json() +} diff --git a/packages/sdk/src/auth/refreshToken.ts b/packages/sdk/src/auth/refreshToken.ts new file mode 100644 index 000000000..1bf8e9dbe --- /dev/null +++ b/packages/sdk/src/auth/refreshToken.ts @@ -0,0 +1,32 @@ +import type { PayloadSDK } from '../index.js' +import type { AuthCollectionSlug, DataFromCollectionSlug, PayloadGeneratedTypes } from '../types.js' + +export type RefreshOptions> = { + collection: TSlug +} + +export type RefreshResult> = { + exp: number + refreshedToken: string + setCookie?: boolean + strategy?: string + // @ts-expect-error auth collection and user collection + user: DataFromCollectionSlug +} + +export async function refreshToken< + T extends PayloadGeneratedTypes, + TSlug extends AuthCollectionSlug, +>( + sdk: PayloadSDK, + options: RefreshOptions, + init?: RequestInit, +): Promise> { + const response = await sdk.request({ + init, + method: 'POST', + path: `/${options.collection}/refresh-token`, + }) + + return response.json() +} diff --git a/packages/sdk/src/auth/resetPassword.ts b/packages/sdk/src/auth/resetPassword.ts new file mode 100644 index 000000000..c20276d7d --- /dev/null +++ b/packages/sdk/src/auth/resetPassword.ts @@ -0,0 +1,40 @@ +import type { PayloadSDK } from '../index.js' +import type { AuthCollectionSlug, DataFromCollectionSlug, PayloadGeneratedTypes } from '../types.js' + +export type ResetPasswordOptions< + T extends PayloadGeneratedTypes, + TSlug extends AuthCollectionSlug, +> = { + collection: TSlug + data: { + password: string + token: string + } +} + +export type ResetPasswordResult< + T extends PayloadGeneratedTypes, + TSlug extends AuthCollectionSlug, +> = { + token?: string + // @ts-expect-error auth collection and user collection + user: DataFromCollectionSlug +} + +export async function resetPassword< + T extends PayloadGeneratedTypes, + TSlug extends AuthCollectionSlug, +>( + sdk: PayloadSDK, + options: ResetPasswordOptions, + init?: RequestInit, +): Promise> { + const response = await sdk.request({ + init, + json: options.data, + method: 'POST', + path: `/${options.collection}/reset-password`, + }) + + return response.json() +} diff --git a/packages/sdk/src/auth/verifyEmail.ts b/packages/sdk/src/auth/verifyEmail.ts new file mode 100644 index 000000000..b7aa170da --- /dev/null +++ b/packages/sdk/src/auth/verifyEmail.ts @@ -0,0 +1,27 @@ +import type { PayloadSDK } from '../index.js' +import type { AuthCollectionSlug, PayloadGeneratedTypes } from '../types.js' + +export type VerifyEmailOptions< + T extends PayloadGeneratedTypes, + TSlug extends AuthCollectionSlug, +> = { + collection: TSlug + token: string +} + +export async function verifyEmail< + T extends PayloadGeneratedTypes, + TSlug extends AuthCollectionSlug, +>( + sdk: PayloadSDK, + options: VerifyEmailOptions, + init?: RequestInit, +): Promise<{ message: string }> { + const response = await sdk.request({ + init, + method: 'POST', + path: `/${options.collection}/verify/${options.token}`, + }) + + return response.json() +} diff --git a/packages/sdk/src/collections/count.ts b/packages/sdk/src/collections/count.ts new file mode 100644 index 000000000..741a9bbfc --- /dev/null +++ b/packages/sdk/src/collections/count.ts @@ -0,0 +1,34 @@ +import type { Where } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { CollectionSlug, PayloadGeneratedTypes, TypedLocale } from '../types.js' + +export type CountOptions> = { + /** + * the Collection slug to operate against. + */ + collection: TSlug + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * A filter [query](https://payloadcms.com/docs/queries/overview) + */ + where?: Where +} + +export async function count>( + sdk: PayloadSDK, + options: CountOptions, + init?: RequestInit, +): Promise<{ totalDocs: number }> { + const response = await sdk.request({ + args: options, + init, + method: 'GET', + path: `/${options.collection}/count`, + }) + + return response.json() +} diff --git a/packages/sdk/src/collections/create.ts b/packages/sdk/src/collections/create.ts new file mode 100644 index 000000000..a443e6e7c --- /dev/null +++ b/packages/sdk/src/collections/create.ts @@ -0,0 +1,84 @@ +import type { SelectType } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { + CollectionSlug, + PayloadGeneratedTypes, + PopulateType, + RequiredDataFromCollectionSlug, + TransformCollectionWithSelect, + TypedLocale, + UploadCollectionSlug, +} from '../types.js' + +import { resolveFileFromOptions } from '../utilities/resolveFileFromOptions.js' + +export type CreateOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectType, +> = { + /** + * the Collection slug to operate against. + */ + collection: TSlug + /** + * The data for the document to create. + */ + data: RequiredDataFromCollectionSlug + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * Create a **draft** document. [More](https://payloadcms.com/docs/versions/drafts#draft-api) + */ + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** File Blob object or URL to the file. Only for upload collections */ + file?: TSlug extends UploadCollectionSlug ? Blob | string : never + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: TSelect +} + +export async function create< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectType, +>( + sdk: PayloadSDK, + options: CreateOptions, + init?: RequestInit, +): Promise> { + let file: Blob | undefined = undefined + + if (options.file) { + file = await resolveFileFromOptions(options.file) + } + + const response = await sdk.request({ + args: options, + file, + init, + json: options.data, + method: 'POST', + path: `/${options.collection}`, + }) + + const json = await response.json() + + return json.doc +} diff --git a/packages/sdk/src/collections/delete.ts b/packages/sdk/src/collections/delete.ts new file mode 100644 index 000000000..841b4b119 --- /dev/null +++ b/packages/sdk/src/collections/delete.ts @@ -0,0 +1,102 @@ +import type { SelectType, Where } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { + BulkOperationResult, + CollectionSlug, + PayloadGeneratedTypes, + PopulateType, + SelectFromCollectionSlug, + TransformCollectionWithSelect, + TypedLocale, +} from '../types.js' + +export type DeleteBaseOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectType, +> = { + /** + * the Collection slug to operate against. + */ + collection: TSlug + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: TypedLocale + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: TSelect +} + +export type DeleteByIDOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectFromCollectionSlug, +> = { + /** + * The ID of the document to delete. + */ + id: number | string + + where?: never +} & DeleteBaseOptions + +export type DeleteManyOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectFromCollectionSlug, +> = { + id?: never + /** + * A filter [query](https://payloadcms.com/docs/queries/overview) + */ + where: Where +} & DeleteBaseOptions + +export type DeleteOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectFromCollectionSlug, +> = DeleteByIDOptions | DeleteManyOptions + +export async function deleteOperation< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectFromCollectionSlug, +>( + sdk: PayloadSDK, + options: DeleteOptions, + init?: RequestInit, +): Promise< + BulkOperationResult | TransformCollectionWithSelect +> { + const response = await sdk.request({ + args: options, + init, + method: 'DELETE', + path: `/${options.collection}${options.id ? `/${options.id}` : ''}`, + }) + + const json = await response.json() + + if (options.id) { + return json.doc + } + + return json +} diff --git a/packages/sdk/src/collections/find.ts b/packages/sdk/src/collections/find.ts new file mode 100644 index 000000000..9c4140cc4 --- /dev/null +++ b/packages/sdk/src/collections/find.ts @@ -0,0 +1,105 @@ +import type { PaginatedDocs, SelectType, Sort, Where } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { + CollectionSlug, + JoinQuery, + PayloadGeneratedTypes, + PopulateType, + TransformCollectionWithSelect, + TypedLocale, +} from '../types.js' + +export type FindOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectType, +> = { + /** + * the Collection slug to operate against. + */ + collection: TSlug + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * Whether the documents should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) + */ + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * The [Join Field Query](https://payloadcms.com/docs/fields/join#query-options). + * Pass `false` to disable all join fields from the result. + */ + joins?: JoinQuery + /** + * The maximum related documents to be returned. + * Defaults unless `defaultLimit` is specified for the collection config + * @default 10 + */ + limit?: number + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Get a specific page number + * @default 1 + */ + page?: number + /** + * Set to `false` to return all documents and avoid querying for document counts which introduces some overhead. + * You can also combine that property with a specified `limit` to limit documents but avoid the count query. + */ + pagination?: boolean + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: TSelect + /** + * Sort the documents, can be a string or an array of strings + * @example '-createdAt' // Sort DESC by createdAt + * @example ['group', '-createdAt'] // sort by 2 fields, ASC group and DESC createdAt + */ + sort?: Sort + /** + * When set to `true`, the query will include both normal and trashed documents. + * To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`. + * By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field. + * + * This argument has no effect unless `trash` is enabled on the collection. + * @default false + */ + trash?: boolean + /** + * A filter [query](https://payloadcms.com/docs/queries/overview) + */ + where?: Where +} + +export async function find< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectType, +>( + sdk: PayloadSDK, + options: FindOptions, + init?: RequestInit, +): Promise>> { + const response = await sdk.request({ + args: options, + init, + method: 'GET', + path: `/${options.collection}`, + }) + + return response.json() +} diff --git a/packages/sdk/src/collections/findByID.ts b/packages/sdk/src/collections/findByID.ts new file mode 100644 index 000000000..e8c8880f2 --- /dev/null +++ b/packages/sdk/src/collections/findByID.ts @@ -0,0 +1,94 @@ +import type { ApplyDisableErrors, SelectType } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { + CollectionSlug, + JoinQuery, + PayloadGeneratedTypes, + PopulateType, + TransformCollectionWithSelect, + TypedLocale, +} from '../types.js' + +export type FindByIDOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TDisableErrors extends boolean, + TSelect extends SelectType, +> = { + /** + * the Collection slug to operate against. + */ + collection: TSlug + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * When set to `true`, errors will not be thrown. + * `null` will be returned instead, if the document on this ID was not found. + */ + disableErrors?: TDisableErrors + /** + * Whether the document should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) + */ + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * The ID of the document to find. + */ + id: number | string + /** + * The [Join Field Query](https://payloadcms.com/docs/fields/join#query-options). + * Pass `false` to disable all join fields from the result. + */ + joins?: JoinQuery + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: TSelect +} + +export async function findByID< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TDisableErrors extends boolean, + TSelect extends SelectType, +>( + sdk: PayloadSDK, + options: FindByIDOptions, + init?: RequestInit, +): Promise, TDisableErrors>> { + try { + const response = await sdk.request({ + args: options, + init, + method: 'GET', + path: `/${options.collection}/${options.id}`, + }) + + if (response.ok) { + return response.json() + } else { + throw new Error() + } + } catch { + if (options.disableErrors) { + // @ts-expect-error generic nullable + return null + } + + throw new Error(`Error retrieving the document ${options.collection}/${options.id}`) + } +} diff --git a/packages/sdk/src/collections/findVersionByID.ts b/packages/sdk/src/collections/findVersionByID.ts new file mode 100644 index 000000000..988c38ed2 --- /dev/null +++ b/packages/sdk/src/collections/findVersionByID.ts @@ -0,0 +1,95 @@ +import type { ApplyDisableErrors, SelectType, TypeWithVersion } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { + CollectionSlug, + DataFromCollectionSlug, + PayloadGeneratedTypes, + PopulateType, + TypedLocale, +} from '../types.js' + +export type FindVersionByIDOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TDisableErrors extends boolean, +> = { + /** + * the Collection slug to operate against. + */ + collection: TSlug + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * When set to `true`, errors will not be thrown. + * `null` will be returned instead, if the document on this ID was not found. + */ + disableErrors?: TDisableErrors + /** + * Whether the document should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) + */ + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * The ID of the version to find. + */ + id: number | string + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: SelectType + /** + * When set to `true`, the operation will return a document by ID, even if it is trashed (soft-deleted). + * By default (`false`), the operation will exclude trashed documents. + * To fetch a trashed document, set `trash: true`. + * + * This argument has no effect unless `trash` is enabled on the collection. + * @default false + */ + trash?: boolean +} + +export async function findVersionByID< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TDisableErrors extends boolean, +>( + sdk: PayloadSDK, + options: FindVersionByIDOptions, + init?: RequestInit, +): Promise>, TDisableErrors>> { + try { + const response = await sdk.request({ + args: options, + init, + method: 'GET', + path: `/${options.collection}/versions/${options.id}`, + }) + + if (response.ok) { + return response.json() + } else { + throw new Error() + } + } catch { + if (options.disableErrors) { + // @ts-expect-error generic nullable + return null + } + + throw new Error(`Error retrieving the version document ${options.collection}/${options.id}`) + } +} diff --git a/packages/sdk/src/collections/findVersions.ts b/packages/sdk/src/collections/findVersions.ts new file mode 100644 index 000000000..485d9f74d --- /dev/null +++ b/packages/sdk/src/collections/findVersions.ts @@ -0,0 +1,97 @@ +import type { PaginatedDocs, SelectType, Sort, TypeWithVersion, Where } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { + CollectionSlug, + DataFromCollectionSlug, + PayloadGeneratedTypes, + PopulateType, + TypedLocale, +} from '../types.js' + +export type FindVersionsOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, +> = { + /** + * the Collection slug to operate against. + */ + collection: TSlug + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * Whether the documents should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) + */ + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * The maximum related documents to be returned. + * Defaults unless `defaultLimit` is specified for the collection config + * @default 10 + */ + limit?: number + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Get a specific page number + * @default 1 + */ + page?: number + /** + * Set to `false` to return all documents and avoid querying for document counts which introduces some overhead. + * You can also combine that property with a specified `limit` to limit documents but avoid the count query. + */ + pagination?: boolean + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: SelectType + /** + * Sort the documents, can be a string or an array of strings + * @example '-version.createdAt' // Sort DESC by createdAt + * @example ['version.group', '-version.createdAt'] // sort by 2 fields, ASC group and DESC createdAt + */ + sort?: Sort + /** + * When set to `true`, the query will include both normal and trashed (soft-deleted) documents. + * To query only trashed documents, pass `trash: true` and combine with a `where` clause filtering by `deletedAt`. + * By default (`false`), the query will only include normal documents and exclude those with a `deletedAt` field. + * + * This argument has no effect unless `trash` is enabled on the collection. + * @default false + */ + trash?: boolean + /** + * A filter [query](https://payloadcms.com/docs/queries/overview) + */ + where?: Where +} + +export async function findVersions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, +>( + sdk: PayloadSDK, + options: FindVersionsOptions, + init?: RequestInit, +): Promise>>> { + const response = await sdk.request({ + args: options, + init, + method: 'GET', + path: `/${options.collection}/versions`, + }) + + return response.json() +} diff --git a/packages/sdk/src/collections/restoreVersion.ts b/packages/sdk/src/collections/restoreVersion.ts new file mode 100644 index 000000000..e79e6e755 --- /dev/null +++ b/packages/sdk/src/collections/restoreVersion.ts @@ -0,0 +1,60 @@ +import type { PayloadSDK } from '../index.js' +import type { + CollectionSlug, + DataFromCollectionSlug, + PayloadGeneratedTypes, + PopulateType, + TypedLocale, +} from '../types.js' + +export type RestoreVersionByIDOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, +> = { + /** + * the Collection slug to operate against. + */ + collection: TSlug + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * Whether the document should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) + */ + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * The ID of the version to restore. + */ + id: number | string + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType +} + +export async function restoreVersion< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, +>( + sdk: PayloadSDK, + options: RestoreVersionByIDOptions, + init?: RequestInit, +): Promise> { + const response = await sdk.request({ + args: options, + init, + method: 'POST', + path: `/${options.collection}/versions/${options.id}`, + }) + + return response.json() +} diff --git a/packages/sdk/src/collections/update.ts b/packages/sdk/src/collections/update.ts new file mode 100644 index 000000000..0f908fd57 --- /dev/null +++ b/packages/sdk/src/collections/update.ts @@ -0,0 +1,128 @@ +import type { SelectType, Where } from 'payload' +import type { DeepPartial } from 'ts-essentials' + +import type { PayloadSDK } from '../index.js' +import type { + BulkOperationResult, + CollectionSlug, + PayloadGeneratedTypes, + PopulateType, + RequiredDataFromCollectionSlug, + SelectFromCollectionSlug, + TransformCollectionWithSelect, + TypedLocale, + UploadCollectionSlug, +} from '../types.js' + +import { resolveFileFromOptions } from '../utilities/resolveFileFromOptions.js' + +export type UpdateBaseOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectType, +> = { + /** + * Whether the current update should be marked as from autosave. + * `versions.drafts.autosave` should be specified. + */ + autosave?: boolean + /** + * the Collection slug to operate against. + */ + collection: TSlug + /** + * The document / documents data to update. + */ + data: DeepPartial> + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * Update documents to a draft. + */ + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** File Blob object or URL to the file. Only for upload collections */ + file?: TSlug extends UploadCollectionSlug ? Blob | string : never + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: TypedLocale + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Publish the document / documents with a specific locale. + */ + publishSpecificLocale?: string + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: TSelect +} + +export type UpdateByIDOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectFromCollectionSlug, +> = { + id: number | string + limit?: never + where?: never +} & UpdateBaseOptions + +export type UpdateManyOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectFromCollectionSlug, +> = { + id?: never + limit?: number + where: Where +} & UpdateBaseOptions + +export type UpdateOptions< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectFromCollectionSlug, +> = UpdateByIDOptions | UpdateManyOptions + +export async function update< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectFromCollectionSlug, +>( + sdk: PayloadSDK, + options: UpdateOptions, + init?: RequestInit, +): Promise< + BulkOperationResult | TransformCollectionWithSelect +> { + let file: Blob | undefined = undefined + + if (options.file) { + file = await resolveFileFromOptions(options.file) + } + + const response = await sdk.request({ + args: options, + file, + init, + json: options.data, + method: 'PATCH', + path: `/${options.collection}${options.id ? `/${options.id}` : ''}`, + }) + + const json = await response.json() + + if (options.id) { + return json.doc + } + + return json +} diff --git a/packages/sdk/src/globals/findOne.ts b/packages/sdk/src/globals/findOne.ts new file mode 100644 index 000000000..13bc8c65e --- /dev/null +++ b/packages/sdk/src/globals/findOne.ts @@ -0,0 +1,65 @@ +import type { SelectType } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { + GlobalSlug, + PayloadGeneratedTypes, + PopulateType, + SelectFromGlobalSlug, + TransformGlobalWithSelect, + TypedLocale, +} from '../types.js' + +export type FindGlobalOptions< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, + TSelect extends SelectType, +> = { + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * Whether the document should be queried from the versions table/collection or not. [More](https://payloadcms.com/docs/versions/drafts#draft-api) + */ + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: TSelect + /** + * the Global slug to operate against. + */ + slug: TSlug +} + +export async function findGlobal< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, + TSelect extends SelectFromGlobalSlug, +>( + sdk: PayloadSDK, + options: FindGlobalOptions, + init?: RequestInit, +): Promise> { + const response = await sdk.request({ + args: options, + init, + method: 'GET', + path: `/globals/${options.slug}`, + }) + + return response.json() +} diff --git a/packages/sdk/src/globals/findVersionByID.ts b/packages/sdk/src/globals/findVersionByID.ts new file mode 100644 index 000000000..6d1657068 --- /dev/null +++ b/packages/sdk/src/globals/findVersionByID.ts @@ -0,0 +1,83 @@ +import type { ApplyDisableErrors, SelectType, TypeWithVersion } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { + DataFromGlobalSlug, + GlobalSlug, + PayloadGeneratedTypes, + PopulateType, + TypedLocale, +} from '../types.js' + +export type FindGlobalVersionByIDOptions< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, + TDisableErrors extends boolean, +> = { + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * When set to `true`, errors will not be thrown. + * `null` will be returned instead, if the document on this ID was not found. + */ + disableErrors?: TDisableErrors + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * The ID of the version to find. + */ + id: number | string + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: SelectType + /** + * the Global slug to operate against. + */ + slug: TSlug +} + +export async function findGlobalVersionByID< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, + TDisableErrors extends boolean, +>( + sdk: PayloadSDK, + options: FindGlobalVersionByIDOptions, + init?: RequestInit, +): Promise>, TDisableErrors>> { + try { + const response = await sdk.request({ + args: options, + init, + method: 'GET', + path: `/globals/${options.slug}/versions/${options.id}`, + }) + + if (response.ok) { + return response.json() + } else { + throw new Error() + } + } catch { + if (options.disableErrors) { + // @ts-expect-error generic nullable + return null + } + + throw new Error(`Error retrieving the version document ${options.slug}/${options.id}`) + } +} diff --git a/packages/sdk/src/globals/findVersions.ts b/packages/sdk/src/globals/findVersions.ts new file mode 100644 index 000000000..366f717e6 --- /dev/null +++ b/packages/sdk/src/globals/findVersions.ts @@ -0,0 +1,85 @@ +import type { PaginatedDocs, SelectType, Sort, TypeWithVersion, Where } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { + DataFromGlobalSlug, + GlobalSlug, + PayloadGeneratedTypes, + PopulateType, + TypedLocale, +} from '../types.js' + +export type FindGlobalVersionsOptions< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, +> = { + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * The maximum related documents to be returned. + * Defaults unless `defaultLimit` is specified for the collection config + * @default 10 + */ + limit?: number + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Get a specific page number + * @default 1 + */ + page?: number + /** + * Set to `false` to return all documents and avoid querying for document counts which introduces some overhead. + * You can also combine that property with a specified `limit` to limit documents but avoid the count query. + */ + pagination?: boolean + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: SelectType + /** + * the Global slug to operate against. + */ + slug: TSlug + /** + * Sort the documents, can be a string or an array of strings + * @example '-version.createdAt' // Sort DESC by createdAt + * @example ['version.group', '-version.createdAt'] // sort by 2 fields, ASC group and DESC createdAt + */ + sort?: Sort + /** + * A filter [query](https://payloadcms.com/docs/queries/overview) + */ + where?: Where +} + +export async function findGlobalVersions< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, +>( + sdk: PayloadSDK, + options: FindGlobalVersionsOptions, + init?: RequestInit, +): Promise>>> { + const response = await sdk.request({ + args: options, + init, + method: 'GET', + path: `/globals/${options.slug}/versions`, + }) + + return response.json() +} diff --git a/packages/sdk/src/globals/restoreVersion.ts b/packages/sdk/src/globals/restoreVersion.ts new file mode 100644 index 000000000..98e06c1bc --- /dev/null +++ b/packages/sdk/src/globals/restoreVersion.ts @@ -0,0 +1,61 @@ +import type { TypeWithVersion } from 'payload' + +import type { PayloadSDK } from '../index.js' +import type { + DataFromGlobalSlug, + GlobalSlug, + PayloadGeneratedTypes, + PopulateType, + TypedLocale, +} from '../types.js' + +export type RestoreGlobalVersionByIDOptions< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, +> = { + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * The ID of the version to restore. + */ + id: number | string + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * the Global slug to operate against. + */ + slug: TSlug +} + +export async function restoreGlobalVersion< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, +>( + sdk: PayloadSDK, + options: RestoreGlobalVersionByIDOptions, + init?: RequestInit, +): Promise>> { + const response = await sdk.request({ + args: options, + init, + method: 'POST', + path: `/globals/${options.slug}/versions/${options.id}`, + }) + + const { doc } = await response.json() + + return doc +} diff --git a/packages/sdk/src/globals/update.ts b/packages/sdk/src/globals/update.ts new file mode 100644 index 000000000..575c13cf6 --- /dev/null +++ b/packages/sdk/src/globals/update.ts @@ -0,0 +1,78 @@ +import type { SelectType } from 'payload' +import type { DeepPartial } from 'ts-essentials' + +import type { PayloadSDK } from '../index.js' +import type { + DataFromGlobalSlug, + GlobalSlug, + PayloadGeneratedTypes, + PopulateType, + SelectFromGlobalSlug, + TransformGlobalWithSelect, + TypedLocale, +} from '../types.js' + +export type UpdateGlobalOptions< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, + TSelect extends SelectType, +> = { + /** + * The global data to update. + */ + data: DeepPartial, 'id'>> + /** + * [Control auto-population](https://payloadcms.com/docs/queries/depth) of nested relationship and upload fields. + */ + depth?: number + /** + * Update documents to a draft. + */ + draft?: boolean + /** + * Specify a [fallback locale](https://payloadcms.com/docs/configuration/localization) to use for any returned documents. + */ + fallbackLocale?: false | TypedLocale + /** + * Specify [locale](https://payloadcms.com/docs/configuration/localization) for any returned documents. + */ + locale?: 'all' | TypedLocale + /** + * Specify [populate](https://payloadcms.com/docs/queries/select#populate) to control which fields to include to the result from populated documents. + */ + populate?: PopulateType + /** + * Publish the document / documents with a specific locale. + */ + publishSpecificLocale?: TypedLocale + /** + * Specify [select](https://payloadcms.com/docs/queries/select) to control which fields to include to the result. + */ + select?: TSelect + /** + * the Global slug to operate against. + */ + slug: TSlug +} + +export async function updateGlobal< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, + TSelect extends SelectFromGlobalSlug, +>( + sdk: PayloadSDK, + options: UpdateGlobalOptions, + init?: RequestInit, +): Promise> { + const response = await sdk.request({ + args: options, + init, + json: options.data, + method: 'POST', + path: `/globals/${options.slug}`, + }) + + const { result } = await response.json() + + return result +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 000000000..90b024ca9 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,374 @@ +import type { ApplyDisableErrors, PaginatedDocs, SelectType, TypeWithVersion } from 'payload' + +import type { ForgotPasswordOptions } from './auth/forgotPassword.js' +import type { LoginOptions, LoginResult } from './auth/login.js' +import type { MeOptions, MeResult } from './auth/me.js' +import type { ResetPasswordOptions, ResetPasswordResult } from './auth/resetPassword.js' +import type { CountOptions } from './collections/count.js' +import type { CreateOptions } from './collections/create.js' +import type { DeleteByIDOptions, DeleteManyOptions, DeleteOptions } from './collections/delete.js' +import type { FindOptions } from './collections/find.js' +import type { FindByIDOptions } from './collections/findByID.js' +import type { FindVersionByIDOptions } from './collections/findVersionByID.js' +import type { FindVersionsOptions } from './collections/findVersions.js' +import type { RestoreVersionByIDOptions } from './collections/restoreVersion.js' +import type { FindGlobalVersionByIDOptions } from './globals/findVersionByID.js' +import type { FindGlobalVersionsOptions } from './globals/findVersions.js' +import type { RestoreGlobalVersionByIDOptions } from './globals/restoreVersion.js' +import type { UpdateGlobalOptions } from './globals/update.js' +import type { + AuthCollectionSlug, + BulkOperationResult, + CollectionSlug, + DataFromCollectionSlug, + DataFromGlobalSlug, + GlobalSlug, + PayloadGeneratedTypes, + SelectFromCollectionSlug, + SelectFromGlobalSlug, + TransformCollectionWithSelect, + TransformGlobalWithSelect, +} from './types.js' +import type { OperationArgs } from './utilities/buildSearchParams.js' + +import { forgotPassword } from './auth/forgotPassword.js' +import { login } from './auth/login.js' +import { me } from './auth/me.js' +import { type RefreshOptions, type RefreshResult, refreshToken } from './auth/refreshToken.js' +import { resetPassword } from './auth/resetPassword.js' +import { verifyEmail, type VerifyEmailOptions } from './auth/verifyEmail.js' +import { count } from './collections/count.js' +import { create } from './collections/create.js' +import { deleteOperation } from './collections/delete.js' +import { find } from './collections/find.js' +import { findByID } from './collections/findByID.js' +import { findVersionByID } from './collections/findVersionByID.js' +import { findVersions } from './collections/findVersions.js' +import { restoreVersion } from './collections/restoreVersion.js' +import { + update, + type UpdateByIDOptions, + type UpdateManyOptions, + type UpdateOptions, +} from './collections/update.js' +import { findGlobal, type FindGlobalOptions } from './globals/findOne.js' +import { findGlobalVersionByID } from './globals/findVersionByID.js' +import { findGlobalVersions } from './globals/findVersions.js' +import { restoreGlobalVersion } from './globals/restoreVersion.js' +import { updateGlobal } from './globals/update.js' +import { buildSearchParams } from './utilities/buildSearchParams.js' + +type Args = { + /** Base passed `RequestInit` to `fetch`. For base headers / credentials include etc. */ + baseInit?: RequestInit + + /** + * Base API URL for requests. + * @example 'https://example.com/api' + */ + baseURL: string + + /** + * This option allows you to pass a custom `fetch` implementation. + * The function always receives `path` as the first parameter and `RequestInit` as the second. + * @example For testing without needing an HTTP server: + * ```typescript + * import type { GeneratedTypes, SanitizedConfig } from 'payload'; + * import config from '@payload-config'; + * import { REST_DELETE, REST_GET, REST_PATCH, REST_POST, REST_PUT } from '@payloadcms/next/routes'; + * import { PayloadSDK } from '@payloadcms/sdk'; + * + * export type TypedPayloadSDK = PayloadSDK; + * + * const api = { + * GET: REST_GET(config), + * POST: REST_POST(config), + * PATCH: REST_PATCH(config), + * DELETE: REST_DELETE(config), + * PUT: REST_PUT(config), + * }; + * + * const awaitedConfig = await config; + * + * export const sdk = new PayloadSDK({ + * baseURL: '', + * fetch: (path: string, init: RequestInit) => { + * const [slugs, search] = path.slice(1).split('?'); + * const url = `${awaitedConfig.serverURL || 'http://localhost:3000'}${awaitedConfig.routes.api}/${slugs}${search ? `?${search}` : ''}`; + * + * if (init.body instanceof FormData) { + * const file = init.body.get('file') as Blob; + * if (file && init.headers instanceof Headers) { + * init.headers.set('Content-Length', file.size.toString()); + * } + * } + * + * const request = new Request(url, init); + * + * const params = { + * params: Promise.resolve({ + * slug: slugs.split('/'), + * }), + * }; + * + * return api[init.method.toUpperCase()](request, params); + * }, + * }); + * ``` + */ + fetch?: typeof fetch +} + +/** + * @experimental + */ +export class PayloadSDK { + baseInit: RequestInit + + baseURL: string + + fetch: typeof fetch + constructor(args: Args) { + this.baseURL = args.baseURL + this.fetch = args.fetch ?? globalThis.fetch + this.baseInit = args.baseInit ?? {} + } + + /** + * @description Performs count operation + * @param options + * @returns count of documents satisfying query + */ + count>( + options: CountOptions, + init?: RequestInit, + ): Promise<{ totalDocs: number }> { + return count(this, options, init) + } + + /** + * @description Performs create operation + * @param options + * @returns created document + */ + create, TSelect extends SelectType>( + options: CreateOptions, + init?: RequestInit, + ): Promise> { + return create(this, options, init) + } + + delete, TSelect extends SelectFromCollectionSlug>( + options: DeleteManyOptions, + init?: RequestInit, + ): Promise> + delete, TSelect extends SelectFromCollectionSlug>( + options: DeleteByIDOptions, + init?: RequestInit, + ): Promise> + + /** + * @description Update one or more documents + * @param options + * @returns Updated document(s) + */ + delete, TSelect extends SelectFromCollectionSlug>( + options: DeleteOptions, + init?: RequestInit, + ): Promise< + BulkOperationResult | TransformCollectionWithSelect + > { + return deleteOperation(this, options, init) + } + + /** + * @description Find documents with criteria + * @param options + * @returns documents satisfying query + */ + find, TSelect extends SelectType>( + options: FindOptions, + init?: RequestInit, + ): Promise>> { + return find(this, options, init) + } + + /** + * @description Find document by ID + * @param options + * @returns document with specified ID + */ + findByID< + TSlug extends CollectionSlug, + TDisableErrors extends boolean, + TSelect extends SelectType, + >( + options: FindByIDOptions, + init?: RequestInit, + ): Promise, TDisableErrors>> { + return findByID(this, options, init) + } + + findGlobal, TSelect extends SelectFromGlobalSlug>( + options: FindGlobalOptions, + init?: RequestInit, + ): Promise> { + return findGlobal(this, options, init) + } + + findGlobalVersionByID, TDisableErrors extends boolean>( + options: FindGlobalVersionByIDOptions, + init?: RequestInit, + ): Promise>, TDisableErrors>> { + return findGlobalVersionByID(this, options, init) + } + + findGlobalVersions>( + options: FindGlobalVersionsOptions, + init?: RequestInit, + ): Promise>>> { + return findGlobalVersions(this, options, init) + } + findVersionByID, TDisableErrors extends boolean>( + options: FindVersionByIDOptions, + init?: RequestInit, + ): Promise< + ApplyDisableErrors>, TDisableErrors> + > { + return findVersionByID(this, options, init) + } + + findVersions>( + options: FindVersionsOptions, + init?: RequestInit, + ): Promise>>> { + return findVersions(this, options, init) + } + forgotPassword>( + options: ForgotPasswordOptions, + init?: RequestInit, + ): Promise<{ message: string }> { + return forgotPassword(this, options, init) + } + + login>( + options: LoginOptions, + init?: RequestInit, + ): Promise> { + return login(this, options, init) + } + + me>( + options: MeOptions, + init?: RequestInit, + ): Promise> { + return me(this, options, init) + } + + refreshToken>( + options: RefreshOptions, + init?: RequestInit, + ): Promise> { + return refreshToken(this, options, init) + } + + async request({ + args = {}, + file, + init: incomingInit, + json, + method, + path, + }: { + args?: OperationArgs + file?: Blob + init?: RequestInit + json?: unknown + method: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT' + path: string + }): Promise { + const headers = new Headers({ ...this.baseInit.headers, ...incomingInit?.headers }) + + const init: RequestInit = { + method, + ...this.baseInit, + ...incomingInit, + headers, + } + + if (json) { + if (file) { + const formData = new FormData() + formData.append('file', file) + formData.append('_payload', JSON.stringify(json)) + init.body = formData + } else { + headers.set('Content-Type', 'application/json') + init.body = JSON.stringify(json) + } + } + + const response = await this.fetch(`${this.baseURL}${path}${buildSearchParams(args)}`, init) + + return response + } + + resetPassword>( + options: ResetPasswordOptions, + init?: RequestInit, + ): Promise> { + return resetPassword(this, options, init) + } + + restoreGlobalVersion>( + options: RestoreGlobalVersionByIDOptions, + init?: RequestInit, + ): Promise>> { + return restoreGlobalVersion(this, options, init) + } + + restoreVersion>( + options: RestoreVersionByIDOptions, + init?: RequestInit, + ): Promise> { + return restoreVersion(this, options, init) + } + + update, TSelect extends SelectFromCollectionSlug>( + options: UpdateManyOptions, + init?: RequestInit, + ): Promise> + + update, TSelect extends SelectFromCollectionSlug>( + options: UpdateByIDOptions, + init?: RequestInit, + ): Promise> + + /** + * @description Update one or more documents + * @param options + * @returns Updated document(s) + */ + update, TSelect extends SelectFromCollectionSlug>( + options: UpdateOptions, + init?: RequestInit, + ): Promise< + BulkOperationResult | TransformCollectionWithSelect + > { + return update(this, options, init) + } + + updateGlobal, TSelect extends SelectFromGlobalSlug>( + options: UpdateGlobalOptions, + init?: RequestInit, + ): Promise> { + return updateGlobal(this, options, init) + } + + verifyEmail>( + options: VerifyEmailOptions, + init?: RequestInit, + ): Promise<{ message: string }> { + return verifyEmail(this, options, init) + } +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 000000000..8ad3a45c0 --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,172 @@ +import type { + JsonObject, + SelectType, + Sort, + StringKeyOf, + TransformDataWithSelect, + TypeWithID, + Where, +} from 'payload' +import type { MarkOptional, NonNever } from 'ts-essentials' + +export interface PayloadGeneratedTypes { + auth: { + [slug: string]: { + forgotPassword: { + email: string + } + login: { + email: string + password: string + } + registerFirstUser: { + email: string + password: string + } + unlock: { + email: string + } + } + } + + collections: { + [slug: string]: JsonObject & TypeWithID + } + collectionsJoins: { + [slug: string]: { + [schemaPath: string]: string + } + } + + collectionsSelect: { + [slug: string]: any + } + db: { + defaultIDType: number | string + } + globals: { + [slug: string]: JsonObject + } + + globalsSelect: { + [slug: string]: any + } + + locale: null | string +} + +export type TypedCollection = T['collections'] + +export type TypedGlobal = T['globals'] + +export type TypedCollectionSelect = T['collectionsSelect'] + +export type TypedCollectionJoins = T['collectionsJoins'] + +export type TypedGlobalSelect = T['globalsSelect'] + +export type TypedAuth = T['auth'] + +export type CollectionSlug = StringKeyOf> + +export type GlobalSlug = StringKeyOf> + +export type AuthCollectionSlug = StringKeyOf> + +export type TypedUploadCollection = NonNever<{ + [K in keyof TypedCollection]: + | 'filename' + | 'filesize' + | 'mimeType' + | 'url' extends keyof TypedCollection[K] + ? TypedCollection[K] + : never +}> + +export type UploadCollectionSlug = StringKeyOf< + TypedUploadCollection +> + +export type DataFromCollectionSlug< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, +> = TypedCollection[TSlug] + +export type DataFromGlobalSlug< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, +> = TypedGlobal[TSlug] + +export type SelectFromCollectionSlug< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, +> = TypedCollectionSelect[TSlug] + +export type SelectFromGlobalSlug< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, +> = TypedGlobalSelect[TSlug] + +export type TransformCollectionWithSelect< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectType, +> = TSelect extends SelectType + ? TransformDataWithSelect, TSelect> + : DataFromCollectionSlug + +export type TransformGlobalWithSelect< + T extends PayloadGeneratedTypes, + TSlug extends GlobalSlug, + TSelect extends SelectType, +> = TSelect extends SelectType + ? TransformDataWithSelect, TSelect> + : DataFromGlobalSlug + +export type RequiredDataFromCollection = MarkOptional< + TData, + 'createdAt' | 'id' | 'sizes' | 'updatedAt' +> + +export type RequiredDataFromCollectionSlug< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, +> = RequiredDataFromCollection> + +export type TypedLocale = NonNullable + +export type JoinQuery> = + TypedCollectionJoins[TSlug] extends Record + ? + | false + | Partial<{ + [K in keyof TypedCollectionJoins[TSlug]]: + | { + count?: boolean + limit?: number + page?: number + sort?: Sort + where?: Where + } + | false + }> + : never + +export type PopulateType = Partial> + +export type IDType< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, +> = DataFromCollectionSlug['id'] + +export type BulkOperationResult< + T extends PayloadGeneratedTypes, + TSlug extends CollectionSlug, + TSelect extends SelectType, +> = { + docs: TransformCollectionWithSelect[] + errors: { + id: DataFromCollectionSlug['id'] + message: string + }[] +} diff --git a/packages/sdk/src/utilities/buildSearchParams.ts b/packages/sdk/src/utilities/buildSearchParams.ts new file mode 100644 index 000000000..09ed88c70 --- /dev/null +++ b/packages/sdk/src/utilities/buildSearchParams.ts @@ -0,0 +1,72 @@ +import type { SelectType, Sort, Where } from 'payload' + +import { stringify } from 'qs-esm' + +export type OperationArgs = { + depth?: number + draft?: boolean + fallbackLocale?: false | string + joins?: false | Record + limit?: number + locale?: string + page?: number + populate?: Record + select?: SelectType + sort?: Sort + where?: Where +} + +export const buildSearchParams = (args: OperationArgs): string => { + const search: Record = {} + + if (typeof args.depth === 'number') { + search.depth = String(args.depth) + } + + if (typeof args.page === 'number') { + search.page = String(args.page) + } + + if (typeof args.limit === 'number') { + search.limit = String(args.limit) + } + + if (typeof args.draft === 'boolean') { + search.draft = String(args.draft) + } + + if (args.fallbackLocale) { + search['fallback-locale'] = String(args.fallbackLocale) + } + + if (args.locale) { + search.locale = args.locale + } + + if (args.sort) { + const sanitizedSort = Array.isArray(args.sort) ? args.sort.join(',') : args.sort + search.sort = sanitizedSort + } + + if (args.select) { + search.select = args.select + } + + if (args.where) { + search.where = args.where + } + + if (args.populate) { + search.populate = args.populate + } + + if (args.joins) { + search.joins = args.joins + } + + if (Object.keys(search).length > 0) { + return stringify(search, { addQueryPrefix: true }) + } + + return '' +} diff --git a/packages/sdk/src/utilities/resolveFileFromOptions.ts b/packages/sdk/src/utilities/resolveFileFromOptions.ts new file mode 100644 index 000000000..9913808fe --- /dev/null +++ b/packages/sdk/src/utilities/resolveFileFromOptions.ts @@ -0,0 +1,11 @@ +export const resolveFileFromOptions = async (file: Blob | string) => { + if (typeof file === 'string') { + const response = await fetch(file) + const fileName = file.split('/').pop() ?? '' + const blob = await response.blob() + + return new File([blob], fileName, { type: blob.type }) + } else { + return file + } +} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 000000000..652023fab --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, // Make sure typescript knows that this module depends on their references + "noEmit": false /* Do not emit outputs. */, + "emitDeclarationOnly": true, + "strict": true, + "outDir": "./dist" /* Specify an output folder for all emitted files. */, + "rootDir": "./src" /* Specify the root folder within your source files. */ + }, + "exclude": [ + "dist", + "build", + "tests", + "test", + "node_modules", + "eslint.config.js", + "src/**/*.spec.js", + "src/**/*.spec.jsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx" + ], + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"], + "references": [{ "path": "../payload" }] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f20b70f5..87537c876 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1478,6 +1478,22 @@ importers: specifier: workspace:* version: link:../payload + packages/sdk: + dependencies: + payload: + specifier: workspace:* + version: link:../payload + qs-esm: + specifier: 7.0.2 + version: 7.0.2 + ts-essentials: + specifier: 10.0.3 + version: 10.0.3(typescript@5.7.3) + devDependencies: + '@payloadcms/eslint-config': + specifier: workspace:* + version: link:../eslint-config + packages/storage-azure: dependencies: '@azure/abort-controller': @@ -2084,6 +2100,9 @@ importers: '@payloadcms/richtext-slate': specifier: workspace:* version: link:../packages/richtext-slate + '@payloadcms/sdk': + specifier: workspace:* + version: link:../packages/sdk '@payloadcms/storage-azure': specifier: workspace:* version: link:../packages/storage-azure diff --git a/test/helpers/getSDK.ts b/test/helpers/getSDK.ts new file mode 100644 index 000000000..6036cd9b6 --- /dev/null +++ b/test/helpers/getSDK.ts @@ -0,0 +1,43 @@ +import type { GeneratedTypes, SanitizedConfig } from 'payload' + +import { REST_DELETE, REST_GET, REST_PATCH, REST_POST, REST_PUT } from '@payloadcms/next/routes' +import { PayloadSDK } from '@payloadcms/sdk' + +export type TypedPayloadSDK = PayloadSDK + +/** + * SDK with a custom fetch to run the routes directly without an HTTP server. + */ +export const getSDK = (config: SanitizedConfig) => { + const api = { + GET: REST_GET(config), + POST: REST_POST(config), + PATCH: REST_PATCH(config), + DELETE: REST_DELETE(config), + PUT: REST_PUT(config), + } + + return new PayloadSDK({ + baseURL: ``, + fetch: (path: string, init: RequestInit) => { + const [slugs, search] = path.slice(1).split('?') + const url = `${config.serverURL || 'http://localhost:3000'}${config.routes.api}/${slugs}${search ? `?${search}` : ''}` + + if (init.body instanceof FormData) { + const file = init.body.get('file') as Blob + if (file && init.headers instanceof Headers) { + init.headers.set('Content-Length', file.size.toString()) + } + } + const request = new Request(url, init) + + const params = { + params: Promise.resolve({ + slug: slugs.split('/'), + }), + } + + return api[init.method.toUpperCase()](request, params) + }, + }) +} diff --git a/test/helpers/initPayloadInt.ts b/test/helpers/initPayloadInt.ts index ffd2584ef..8699861d6 100644 --- a/test/helpers/initPayloadInt.ts +++ b/test/helpers/initPayloadInt.ts @@ -1,9 +1,11 @@ -import type { Payload, SanitizedConfig } from 'payload' +import type { PayloadSDK } from '@payloadcms/sdk' +import type { GeneratedTypes, Payload, SanitizedConfig } from 'payload' import path from 'path' import { getPayload } from 'payload' import { runInit } from '../runInit.js' +import { getSDK } from './getSDK.js' import { NextRESTClient } from './NextRESTClient.js' /** @@ -17,7 +19,12 @@ export async function initPayloadInt + } > { const testSuiteName = testSuiteNameOverride ?? path.basename(dirname) await runInit(testSuiteName, false, true, configFile) @@ -34,5 +41,6 @@ export async function initPayloadInt true, update: () => true, delete: () => true, read: () => true }, + fields: [ + { + name: 'text', + type: 'text', + }, + { + name: 'number', + type: 'number', + }, + { + name: 'number2', + type: 'number', + }, + { + name: 'group', + type: 'group', + fields: [ + { + name: 'text', + type: 'text', + }, + { + name: 'number', + type: 'number', + }, + ], + }, + ], + versions: true, +} diff --git a/test/sdk/collections/Users.ts b/test/sdk/collections/Users.ts new file mode 100644 index 000000000..81afe8dbd --- /dev/null +++ b/test/sdk/collections/Users.ts @@ -0,0 +1,14 @@ +import type { CollectionConfig } from 'payload' + +export const Users: CollectionConfig = { + slug: 'users', + auth: true, + admin: { + useAsTitle: 'email', + }, + access: {}, + fields: [ + // Email added by default + // Add more fields as needed + ], +} diff --git a/test/sdk/config.ts b/test/sdk/config.ts new file mode 100644 index 000000000..5767c2cbf --- /dev/null +++ b/test/sdk/config.ts @@ -0,0 +1,51 @@ +import { fileURLToPath } from 'node:url' +import path from 'path' +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { devUser } from '../credentials.js' +import { PostsCollection } from './collections/Posts.js' +import { Users } from './collections/Users.js' + +export default buildConfigWithDefaults({ + admin: { + importMap: { + baseDir: path.resolve(dirname), + }, + }, + collections: [ + Users, + PostsCollection, + { + access: { create: () => true, read: () => true, update: () => true }, + slug: 'media', + upload: { staticDir: path.resolve(dirname, './media') }, + fields: [], + }, + ], + globals: [ + { + slug: 'global', + fields: [{ type: 'text', name: 'text' }], + versions: true, + }, + ], + localization: { + defaultLocale: 'en', + fallback: true, + locales: ['en', 'es', 'de'], + }, + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/sdk/eslint.config.js b/test/sdk/eslint.config.js new file mode 100644 index 000000000..d6b5fea55 --- /dev/null +++ b/test/sdk/eslint.config.js @@ -0,0 +1,19 @@ +import { rootParserOptions } from '../../eslint.config.js' +import testEslintConfig from '../eslint.config.js' + +/** @typedef {import('eslint').Linter.Config} Config */ + +/** @type {Config[]} */ +export const index = [ + ...testEslintConfig, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: import.meta.dirname, + ...rootParserOptions, + }, + }, + }, +] + +export default index diff --git a/test/sdk/image.jpg b/test/sdk/image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4d2dfc3457f77d859683d71997d0af53c0585dae GIT binary patch literal 86124 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 { + beforeAll(async () => { + ;({ payload, sdk } = await initPayloadInt(dirname)) + + post = await payload.create({ collection: 'posts', data: { number: 1, number2: 3 } }) + await payload.create({ + collection: 'users', + data: { ...testUserCredentials }, + }) + await payload.updateGlobal({ slug: 'global', data: { text: 'some-global' } }) + }) + + afterAll(async () => { + if (typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }) + + it('should execute find', async () => { + const result = await sdk.find({ collection: 'posts', where: { id: { equals: post.id } } }) + + expect(result.docs[0].id).toBe(post.id) + }) + + it('should execute findVersions', async () => { + const result = await sdk.findVersions({ + collection: 'posts', + where: { parent: { equals: post.id } }, + }) + + expect(result.docs[0].parent).toBe(post.id) + }) + + it('should execute findByID', async () => { + const result = await sdk.findByID({ collection: 'posts', id: post.id }) + + expect(result.id).toBe(post.id) + }) + + it('should execute findByID with disableErrors: true', async () => { + const result = await sdk.findByID({ + disableErrors: true, + collection: 'posts', + // eslint-disable-next-line jest/no-conditional-in-test + id: typeof post.id === 'string' ? randomUUID() : 999, + }) + + expect(result).toBeNull() + }) + + it('should execute findVersionByID', async () => { + const { + docs: [version], + } = await payload.findVersions({ collection: 'posts', where: { parent: { equals: post.id } } }) + + const result = await sdk.findVersionByID({ collection: 'posts', id: version.id }) + + expect(result.id).toBe(version.id) + }) + + it('should execute create', async () => { + const result = await sdk.create({ collection: 'posts', data: { text: 'text' } }) + + expect(result.text).toBe('text') + }) + + it('should execute create with file', async () => { + const filePath = path.join(dirname, './image.jpg') + const { file, handle } = await createStreamableFile(filePath) + const res = await sdk.create({ collection: 'media', file, data: {} }) + expect(res.id).toBeTruthy() + await handle.close() + }) + + it('should execute count', async () => { + const result = await sdk.count({ collection: 'posts', where: { id: { equals: post.id } } }) + + expect(result.totalDocs).toBe(1) + }) + + it('should execute update (by ID)', async () => { + const result = await sdk.update({ + collection: 'posts', + id: post.id, + data: { text: 'updated-text' }, + }) + + expect(result.text).toBe('updated-text') + }) + + it('should execute update (bulk)', async () => { + const result = await sdk.update({ + collection: 'posts', + where: { + id: { + equals: post.id, + }, + }, + data: { text: 'updated-text-bulk' }, + }) + + expect(result.docs[0].text).toBe('updated-text-bulk') + }) + + it('should execute delete (by ID)', async () => { + const post = await payload.create({ collection: 'posts', data: {} }) + + const result = await sdk.delete({ id: post.id, collection: 'posts' }) + + expect(result.id).toBe(post.id) + + const resultLocal = await payload.findByID({ + collection: 'posts', + id: post.id, + disableErrors: true, + }) + + expect(resultLocal).toBeNull() + }) + + it('should execute delete (bulk)', async () => { + const post = await payload.create({ collection: 'posts', data: {} }) + + const result = await sdk.delete({ where: { id: { equals: post.id } }, collection: 'posts' }) + + expect(result.docs[0].id).toBe(post.id) + + const resultLocal = await payload.findByID({ + collection: 'posts', + id: post.id, + disableErrors: true, + }) + + expect(resultLocal).toBeNull() + }) + + it('should execute restoreVersion', async () => { + const post = await payload.create({ collection: 'posts', data: { text: 'old' } }) + + const { + docs: [currentVersion], + } = await payload.findVersions({ collection: 'posts', where: { parent: { equals: post.id } } }) + + await payload.update({ collection: 'posts', id: post.id, data: { text: 'new' } }) + + const result = await sdk.restoreVersion({ + collection: 'posts', + id: currentVersion.id, + }) + + expect(result.text).toBe('old') + + const resultDB = await payload.findByID({ collection: 'posts', id: post.id }) + + expect(resultDB.text).toBe('old') + }) + + it('should execute findGlobal', async () => { + const result = await sdk.findGlobal({ slug: 'global' }) + expect(result.text).toBe('some-global') + }) + + it('should execute findGlobalVersions', async () => { + const result = await sdk.findGlobalVersions({ + slug: 'global', + }) + + expect(result.docs[0].version).toBeTruthy() + }) + + it('should execute findGlobalVersionByID', async () => { + const { + docs: [version], + } = await payload.findGlobalVersions({ + slug: 'global', + }) + + const result = await sdk.findGlobalVersionByID({ id: version.id, slug: 'global' }) + + expect(result.id).toBe(version.id) + }) + + it('should execute updateGlobal', async () => { + const result = await sdk.updateGlobal({ slug: 'global', data: { text: 'some-updated-global' } }) + expect(result.text).toBe('some-updated-global') + }) + + it('should execute restoreGlobalVersion', async () => { + await payload.updateGlobal({ slug: 'global', data: { text: 'old' } }) + + const { + docs: [currentVersion], + } = await payload.findGlobalVersions({ + slug: 'global', + }) + + await payload.updateGlobal({ slug: 'global', data: { text: 'new' } }) + + const { version: result } = await sdk.restoreGlobalVersion({ + slug: 'global', + id: currentVersion.id, + }) + + expect(result.text).toBe('old') + + const resultDB = await payload.findGlobal({ slug: 'global' }) + + expect(resultDB.text).toBe('old') + }) + + it('should execute login', async () => { + const res = await sdk.login({ + collection: 'users', + data: { email: testUserCredentials.email, password: testUserCredentials.password }, + }) + + expect(res.user.email).toBe(testUserCredentials.email) + }) + + it('should execute me', async () => { + const { token } = await sdk.login({ + collection: 'users', + data: { email: testUserCredentials.email, password: testUserCredentials.password }, + }) + + const res = await sdk.me( + { collection: 'users' }, + { headers: { Authorization: `JWT ${token}` } }, + ) + + expect(res.user.email).toBe(testUserCredentials.email) + }) + + it('should execute refreshToken', async () => { + const { token } = await sdk.login({ + collection: 'users', + data: { email: testUserCredentials.email, password: testUserCredentials.password }, + }) + + const res = await sdk.refreshToken( + { collection: 'users' }, + { headers: { Authorization: `JWT ${token}` } }, + ) + + expect(res.user.email).toBe(testUserCredentials.email) + }) + + it('should execute forgotPassword and resetPassword', async () => { + const user = await payload.create({ + collection: 'users', + data: { email: 'new@payloadcms.com', password: 'HOW TO rEmeMber this password' }, + }) + + const resForgotPassword = await sdk.forgotPassword({ + collection: 'users', + data: { email: user.email }, + }) + + expect(resForgotPassword.message).toBeTruthy() + + const afterForgotPassword = await payload.findByID({ + showHiddenFields: true, + collection: 'users', + id: user.id, + }) + + expect(afterForgotPassword.resetPasswordToken).toBeTruthy() + + const verifyEmailResult = await sdk.resetPassword({ + collection: 'users', + data: { password: '1234567', token: afterForgotPassword.resetPasswordToken }, + }) + + expect(verifyEmailResult.user.email).toBe(user.email) + + const { + user: { email }, + } = await sdk.login({ + collection: 'users', + data: { email: user.email, password: '1234567' }, + }) + + expect(email).toBe(user.email) + }) +}) diff --git a/test/sdk/payload-types.ts b/test/sdk/payload-types.ts new file mode 100644 index 000000000..803da596a --- /dev/null +++ b/test/sdk/payload-types.ts @@ -0,0 +1,291 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +export interface Config { + auth: { + users: UserAuthOperations; + }; + collections: { + users: User; + posts: Post; + media: Media; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + users: UsersSelect | UsersSelect; + posts: PostsSelect | PostsSelect; + media: MediaSelect | MediaSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: string; + }; + globals: { + global: Global; + }; + globalsSelect: { + global: GlobalSelect | GlobalSelect; + }; + locale: 'en' | 'es' | 'de'; + user: User & { + collection: 'users'; + }; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + password?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts". + */ +export interface Post { + id: string; + text?: string | null; + number?: number | null; + number2?: number | null; + group?: { + text?: string | null; + number?: number | null; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media". + */ +export interface Media { + id: string; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: string; + document?: + | ({ + relationTo: 'users'; + value: string | User; + } | null) + | ({ + relationTo: 'posts'; + value: string | Post; + } | null) + | ({ + relationTo: 'media'; + value: string | Media; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: string | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: string; + user: { + relationTo: 'users'; + value: string | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: string; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts_select". + */ +export interface PostsSelect { + text?: T; + number?: T; + number2?: T; + group?: + | T + | { + text?: T; + number?: T; + }; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media_select". + */ +export interface MediaSelect { + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "global". + */ +export interface Global { + id: string; + text?: string | null; + updatedAt?: string | null; + createdAt?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "global_select". + */ +export interface GlobalSelect { + text?: T; + updatedAt?: T; + createdAt?: T; + globalType?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/test/sdk/shared.ts b/test/sdk/shared.ts new file mode 100644 index 000000000..748a8a017 --- /dev/null +++ b/test/sdk/shared.ts @@ -0,0 +1 @@ +export const pagesSlug = 'pages' diff --git a/test/sdk/tsconfig.eslint.json b/test/sdk/tsconfig.eslint.json new file mode 100644 index 000000000..b34cc7afb --- /dev/null +++ b/test/sdk/tsconfig.eslint.json @@ -0,0 +1,13 @@ +{ + // extend your base config to share compilerOptions, etc + //"extends": "./tsconfig.json", + "compilerOptions": { + // ensure that nobody can accidentally use this config for a build + "noEmit": true + }, + "include": [ + // whatever paths you intend to lint + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/test/sdk/tsconfig.json b/test/sdk/tsconfig.json new file mode 100644 index 000000000..3c43903cf --- /dev/null +++ b/test/sdk/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +} diff --git a/test/setupProd.ts b/test/setupProd.ts index 69f568097..138ebfa47 100644 --- a/test/setupProd.ts +++ b/test/setupProd.ts @@ -35,6 +35,7 @@ export const tgzToPkgNameMap = { '@payloadcms/plugin-stripe': 'payloadcms-plugin-stripe-*', '@payloadcms/richtext-lexical': 'payloadcms-richtext-lexical-*', '@payloadcms/richtext-slate': 'payloadcms-richtext-slate-*', + '@payloadcms/sdk': 'payloadcms-sdk-*', '@payloadcms/storage-azure': 'payloadcms-storage-azure-*', '@payloadcms/storage-gcs': 'payloadcms-storage-gcs-*', '@payloadcms/storage-s3': 'payloadcms-storage-s3-*', diff --git a/tsconfig.json b/tsconfig.json index 353ae6cb8..58c299a78 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -84,6 +84,9 @@ { "path": "./packages/ui" }, + { + "path": "./packages/sdk" + } ], "include": [ "${configDir}/src",