From 3af3a91c87ccb31806e7bd8a9d33f009c58f2c85 Mon Sep 17 00:00:00 2001 From: Kendell Joseph <1900724+kendelljoseph@users.noreply.github.com> Date: Fri, 19 Apr 2024 13:35:59 -0400 Subject: [PATCH] feat: json field schemas (#5898) --- docs/fields/json.mdx | 69 ++++++++++++++++++- package.json | 1 + packages/payload/src/fields/config/schema.ts | 1 + packages/payload/src/fields/config/types.ts | 1 + packages/payload/src/fields/validations.ts | 57 ++++++++++++++- packages/ui/src/fields/JSON/index.tsx | 22 ++++++ .../ComponentMap/buildComponentMap/fields.tsx | 1 + pnpm-lock.yaml | 3 + test/fields/collections/JSON/index.tsx | 15 ++++ test/fields/int.spec.ts | 11 +++ 10 files changed, 177 insertions(+), 4 deletions(-) diff --git a/docs/fields/json.mdx b/docs/fields/json.mdx index 31f079d7af..be1ac514fc 100644 --- a/docs/fields/json.mdx +++ b/docs/fields/json.mdx @@ -4,7 +4,7 @@ label: JSON order: 50 desc: The JSON field type will store any string in the Database. Learn how to use JSON fields, see examples and options. -keywords: json, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express +keywords: json, jsonSchema, schema, validation, fields, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express --- @@ -30,6 +30,7 @@ This field uses the `monaco-react` editor syntax highlighting. | **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | | **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | | **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) | +| **`jsonSchema`** | Provide a JSON schema that will be used for validation. [JSON schemas](https://json-schema.org/learn/getting-started-step-by-step) | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. | | **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) | | **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) | @@ -52,7 +53,7 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf ### Example -`collections/ExampleCollection.ts +`collections/ExampleCollection.ts` ```ts import { CollectionConfig } from 'payload/types' @@ -68,3 +69,67 @@ export const ExampleCollection: CollectionConfig = { ], } ``` +### JSON Schema Validation + +Payload JSON fields fully support the [JSON schema](https://json-schema.org/) standard. By providing a schema in your field config, the editor will be guided in the admin UI, getting typeahead for properties and their formats automatically. When the document is saved, the default validation will prevent saving any invalid data in the field according to the schema in your config. + +If you only provide a URL to a schema, Payload will fetch the desired schema if it is publicly available. If not, it is recommended to add the schema directly to your config or import it from another file so that it can be implemented consistently in your project. + + +#### Local JSON Schema + +`collections/ExampleCollection.ts` + +```ts +import { CollectionConfig } from 'payload/types' + +export const ExampleCollection: CollectionConfig = { + slug: 'example-collection', + fields: [ + { + name: 'customerJSON', // required + type: 'json', // required + jsonSchema: { + uri: 'a://b/foo.json', // required + fileMatch: ['a://b/foo.json'], // required + schema: { + type: 'object', + properties: { + foo: { + enum: ['bar', 'foobar'], + } + }, + }, + }, + + }, + ], +} +// {"foo": "bar"} or {"foo": "foobar"} - ok +// Attempting to create {"foo": "not-bar"} will throw an error +``` + +#### Remote JSON Schema + +`collections/ExampleCollection.ts` + +```ts +import { CollectionConfig } from 'payload/types' + +export const ExampleCollection: CollectionConfig = { + slug: 'example-collection', + fields: [ + { + name: 'customerJSON', // required + type: 'json', // required + jsonSchema: { + uri: 'https://example.com/customer.schema.json', // required + fileMatch: ['https://example.com/customer.schema.json'], // required + }, + }, + ], +} +// If 'https://example.com/customer.schema.json' has a JSON schema +// {"foo": "bar"} or {"foo": "foobar"} - ok +// Attempting to create {"foo": "not-bar"} will throw an error +``` diff --git a/package.json b/package.json index 5e53a02927..2f729abbf7 100644 --- a/package.json +++ b/package.json @@ -175,6 +175,7 @@ }, "dependencies": { "@sentry/react": "^7.77.0", + "ajv": "^8.12.0", "passport-strategy": "1.0.0" }, "pnpm": { diff --git a/packages/payload/src/fields/config/schema.ts b/packages/payload/src/fields/config/schema.ts index 08f5036ce6..07c0e6bf62 100644 --- a/packages/payload/src/fields/config/schema.ts +++ b/packages/payload/src/fields/config/schema.ts @@ -196,6 +196,7 @@ export const json = baseField.keys({ editorOptions: joi.object().unknown(), // Editor['options'] @monaco-editor/react }), defaultValue: joi.alternatives().try(joi.array(), joi.object()), + jsonSchema: joi.object().unknown(), }) export const select = baseField.keys({ diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index dcad77837e..8af130f20d 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -489,6 +489,7 @@ type JSONAdmin = Admin & { export type JSONField = Omit & { admin?: JSONAdmin + jsonSchema?: Record type: 'json' } diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index 80dbdd6f8d..9c6cc36f0e 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -1,3 +1,5 @@ +import Ajv from 'ajv' + import type { RichTextAdapter } from '../admin/types.js' import type { Where } from '../types/index.js' import type { @@ -161,10 +163,47 @@ export const code: Validate = ( return true } -export const json: Validate = ( +export const json: Validate = async ( value, - { jsonError, req: { t }, required }, + { jsonError, jsonSchema, req: { t }, required }, ) => { + const isNotEmpty = (value) => { + if (value === undefined || value === null) { + return false + } + + if (Array.isArray(value) && value.length === 0) { + return false + } + + if (typeof value === 'object' && Object.keys(value).length === 0) { + return false + } + + return true + } + + const fetchSchema = ({ schema, uri }: Record) => { + if (uri && schema) return schema + // @ts-expect-error + return fetch(uri) + .then((response) => { + if (!response.ok) { + throw new Error('Network response was not ok') + } + return response.json() + }) + .then((json) => { + const jsonSchemaSanitizations = { + id: undefined, + $id: json.id, + $schema: 'http://json-schema.org/draft-07/schema#', + } + + return Object.assign(json, jsonSchemaSanitizations) + }) + } + if (required && !value) { return t('validation:required') } @@ -173,6 +212,20 @@ export const json: Validate label?: FieldBase['label'] name?: string path?: string @@ -40,6 +41,7 @@ const JSONFieldComponent: React.FC = (props) => { descriptionProps, editorOptions, errorProps, + jsonSchema, label, labelProps, path: pathFromProps, @@ -70,6 +72,25 @@ const JSONFieldComponent: React.FC = (props) => { validate: memoizedValidate, }) + const handleMount = useCallback( + (editor, monaco) => { + if (!jsonSchema) return + + const existingSchemas = monaco.languages.json.jsonDefaults.diagnosticsOptions.schemas || [] + const modelUri = monaco.Uri.parse(jsonSchema.uri) + + const model = monaco.editor.createModel(JSON.stringify(value, null, 2), 'json', modelUri) + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + enableSchemaRequest: true, + schemas: [...existingSchemas, jsonSchema], + validate: true, + }) + + editor.setModel(model) + }, + [jsonSchema, value], + ) + const handleChange = useCallback( (val) => { if (readOnly) return @@ -122,6 +143,7 @@ const JSONFieldComponent: React.FC = (props) => { { ).rejects.toThrow('The following field is invalid: json') }) + it('should validate json schema', async () => { + await expect(async () => + payload.create({ + collection: 'json-fields', + data: { + json: { foo: 'bad' }, + }, + }), + ).rejects.toThrow('The following field is invalid: json') + }) + it('should save empty json objects', async () => { const jsonFieldsDoc = await payload.create({ collection: 'json-fields',