Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/payload/src/fields/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1147,7 +1147,8 @@ export type JSONField = {

jsonSchema?: {
fileMatch: string[]
schema: JSONSchema4
/** Inline schema. When omitted, the schema is fetched from `uri`. */
schema?: JSONSchema4
uri: string
}
type: 'json'
Expand Down
95 changes: 95 additions & 0 deletions packages/payload/src/fields/validateJSONSchema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { JSONSchema4 } from 'json-schema'

import { validateJSONSchema } from './validateJSONSchema.js'
import { afterEach, describe, expect, it, vitest } from 'vitest'

const customerSchema: JSONSchema4 = {
type: 'object',
properties: {
a: {
type: 'number',
},
},
required: ['a'],
}

const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
headers: { 'content-type': 'application/json' },
status,
})

describe('validateJSONSchema', () => {
afterEach(() => {
vitest.unstubAllGlobals()
})

it('rejects a value that violates an inline schema without fetching', async () => {
const fetchMock = vitest.fn()
vitest.stubGlobal('fetch', fetchMock)

const result = await validateJSONSchema({
jsonSchema: {
fileMatch: ['a://b/foo.json'],
schema: customerSchema,
uri: 'a://b/foo.json',
},
value: { a: 'x' },
})

expect(result).not.toBe(true)
expect(typeof result).toBe('string')
expect(fetchMock).not.toHaveBeenCalled()
})

it('fetches a remote schema from uri and rejects a value that violates it', async () => {
const fetchMock = vitest.fn(async () => jsonResponse(customerSchema))
vitest.stubGlobal('fetch', fetchMock)

const jsonSchema: {
fileMatch: string[]
schema?: JSONSchema4
uri: string
} = {
fileMatch: ['https://example.com/customer.schema.json'],
uri: 'https://example.com/customer.schema.json',
}

const invalid = await validateJSONSchema({
jsonSchema,
value: { a: 'x' },
})

expect(fetchMock).toHaveBeenCalledTimes(1)
expect(invalid).not.toBe(true)
expect(typeof invalid).toBe('string')
expect(invalid).toContain('a')
expect(jsonSchema.schema).not.toBeInstanceOf(Promise)

const valid = await validateJSONSchema({
jsonSchema,
value: { a: 1 },
})

expect(valid).toBe(true)
expect(fetchMock).toHaveBeenCalledTimes(1)
})

it('reports a failed fetch instead of accepting every value when uri is missing', async () => {
const fetchMock = vitest.fn(async () => {
throw new TypeError('Failed to parse URL from undefined')
})
vitest.stubGlobal('fetch', fetchMock)

const result = await validateJSONSchema({
jsonSchema: {
fileMatch: ['a://b/foo.json'],
schema: customerSchema,
} as any,
value: { a: 'x' },
})

expect(result).not.toBe(true)
expect(typeof result).toBe('string')
})
})
4 changes: 2 additions & 2 deletions packages/payload/src/fields/validateJSONSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type Args = {
* admin never validates JSON Schemas in the browser, and doesn't need to compile or download zod.
*/
export const validateJSONSchema = async ({ jsonSchema, value }: Args): Promise<string | true> => {
const fetchSchema = ({ schema, uri }: { schema: JSONSchema4; uri: string }) => {
const fetchSchema = ({ schema, uri }: { schema?: JSONSchema4; uri: string }) => {
if (uri && schema) {
return schema
}
Expand All @@ -42,7 +42,7 @@ export const validateJSONSchema = async ({ jsonSchema, value }: Args): Promise<s
}

try {
jsonSchema.schema = fetchSchema(jsonSchema)
jsonSchema.schema = await fetchSchema(jsonSchema)
const { schema } = jsonSchema
const { fromJSONSchema } = await import('zod')
// `JSONSchema4` allows any string for `$schema`, while zod narrows it to the three drafts it
Expand Down
Loading