sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
403 lines (359 loc) • 13 kB
text/typescript
import {type SanityClient} from '@sanity/client'
import {
isKeyedObject,
isTypedObject,
type Rule,
type SanityDocument,
type Schema,
type SchemaType,
type ValidationMarker,
} from '@sanity/types'
import {createClientConcurrencyLimiter} from '@sanity/util/client'
import {ConcurrencyLimiter} from '@sanity/util/concurrency-limiter'
import {flatten, uniqBy} from 'lodash'
import {concat, defer, from, lastValueFrom, merge, Observable, of} from 'rxjs'
import {catchError, map, mergeAll, mergeMap, switchMap, toArray} from 'rxjs/operators'
import {type SourceClientOptions, type Workspace} from '../config'
import {getFallbackLocaleSource} from '../i18n/fallback'
import {type ValidationContext} from './types'
import {createBatchedGetDocumentExists} from './util/createBatchedGetDocumentExists'
import {getTypeChain, normalizeValidationRules} from './util/normalizeValidationRules'
import {cancelIdleCallback, requestIdleCallback} from './util/requestIdleCallback'
import {typeString} from './util/typeString'
import {unknownFieldsValidator} from './validators/unknownFieldsValidator'
// this is the number of requests allowed inflight at once. this is done to prevent
// the validation library from overwhelming our backend
const MAX_FETCH_CONCURRENCY = 10
const limitConcurrency = createClientConcurrencyLimiter(MAX_FETCH_CONCURRENCY)
const isRecord = (maybeRecord: unknown): maybeRecord is Record<string, unknown> =>
typeof maybeRecord === 'object' && maybeRecord !== null && !Array.isArray(maybeRecord)
const isNonNullable = <T>(value: T): value is NonNullable<T> =>
value !== null && value !== undefined
/**
* @internal
*/
export function resolveTypeForArrayItem(
item: unknown,
candidates: SchemaType[],
): SchemaType | undefined {
// if there is only one type available, assume that it's the correct one
if (candidates.length === 1) return candidates[0]
const itemType = isTypedObject(item) && item._type
const primitive =
item === undefined || item === null || (!itemType && typeString(item).toLowerCase())
if (primitive && primitive !== 'object') {
return candidates.find((candidate) => candidate.jsonType === primitive)
}
return (
candidates.find((candidate) => candidate.type?.name === itemType) ||
candidates.find((candidate) => candidate.name === itemType) ||
candidates.find((candidate) => candidate.name === 'object' && primitive === 'object')
)
}
/**
* @beta
*/
export interface ValidateDocumentOptions {
/**
* The document to be validated
*/
document: SanityDocument
/**
* The workspace instance (and associated schema) used to validate the given
* document against.
*/
workspace: Workspace
/**
* Function used to check if referenced documents exists (and is published).
*
* If you're validating many documents in bulk, you may want to query for all
* document IDs first and provide your own implementation using those.
*
* If no function is provided a default one will be provided that will batch
* call the `doc` endpoint to check for document existence.
*/
getDocumentExists?: (options: {id: string}) => Promise<boolean>
/**
* The factory function used to get a sanity client used in custom validators.
* If not provided, the one from the workspace will be used (preferred).
*
* @deprecated For internal use only
*/
getClient?: (clientOptions: SourceClientOptions) => SanityClient
/**
* Specify the environment name. This will be passed down to the
* `ValidationContext` and made available to custom validators.
*/
environment?: 'cli' | 'studio'
/**
* The maximum amount of custom validation functions to be running
* concurrently at once. This helps prevent custom validators from
* overwhelming backend services (e.g. called via fetch) used in async,
* user-defined validation functions. (i.e. `rule.custom(async() => {})`)
*/
maxCustomValidationConcurrency?: number
}
/**
* Validates a document against the schema in the given workspace. Returns an
* array of validation markers with a path, message, and validation level.
*
* @beta
*/
export function validateDocument({
document,
workspace,
environment = 'studio',
...options
}: ValidateDocumentOptions): Promise<ValidationMarker[]> {
const getClient = options.getClient || workspace.getClient
const getConcurrencyLimitedClient = (clientOptions: SourceClientOptions) =>
limitConcurrency(getClient(clientOptions))
return lastValueFrom(
validateDocumentObservable({
document,
getClient: getConcurrencyLimitedClient,
i18n: workspace.i18n,
schema: workspace.schema,
getDocumentExists:
options.getDocumentExists ||
createBatchedGetDocumentExists(getClient({apiVersion: 'v2021-03-25'})),
environment,
}),
)
}
/**
* @internal
*/
export interface ValidateDocumentObservableOptions
extends Pick<ValidationContext, 'getDocumentExists' | 'i18n'> {
getClient: (options: {apiVersion: string}) => SanityClient
document: SanityDocument
schema: Schema
environment: 'cli' | 'studio'
maxCustomValidationConcurrency?: number
}
const customValidationConcurrencyLimiters = new WeakMap<Schema, ConcurrencyLimiter>()
/**
* Validates a document against the given schema, returning an Observable
* @internal
*/
export function validateDocumentObservable({
document,
getClient,
i18n = getFallbackLocaleSource(),
schema,
getDocumentExists,
environment,
maxCustomValidationConcurrency,
}: ValidateDocumentObservableOptions): Observable<ValidationMarker[]> {
if (typeof document?._type !== 'string') {
throw new Error(`Tried to validated a value without a '_type'`)
}
const documentType = schema.get(document._type)
if (!documentType) {
if (environment === 'studio') {
console.warn(
'Schema type for object type "%s" not found, skipping validation',
document._type,
)
return of([])
}
return of([
{
level: 'warning',
message: `Could not find schema type for type '${document._type}', skipping validation`,
path: [],
},
])
}
let customValidationConcurrencyLimiter = customValidationConcurrencyLimiters.get(schema)
if (!customValidationConcurrencyLimiter && maxCustomValidationConcurrency) {
customValidationConcurrencyLimiter = new ConcurrencyLimiter(maxCustomValidationConcurrency)
customValidationConcurrencyLimiters.set(schema, customValidationConcurrencyLimiter)
}
const validationOptions: ValidateItemOptions = {
getClient,
schema,
parent: undefined,
value: document,
path: [],
document: document,
type: documentType,
i18n,
getDocumentExists,
environment,
customValidationConcurrencyLimiter,
}
return from(i18n.loadNamespaces(['validation'])).pipe(
switchMap(() => validateItemObservable(validationOptions)),
catchError((err) => {
console.error(err)
const message = err?.message || 'Unknown error'
const errorMarker: ValidationMarker = {
level: 'error',
message,
item: {message},
path: [],
}
return of([errorMarker])
}),
)
}
/**
* this is used make optional properties required by replacing optionals with
* `T[P] | undefined`. this is used to prevent errors in `validateItem` where
* an option from a previous invocation would be incorrectly passed down.
*
* https://medium.com/terria/typescript-transforming-optional-properties-to-required-properties-that-may-be-undefined-7482cb4e1585
*/
type ExplicitUndefined<T> = {
[P in keyof Required<T>]: Pick<T, P> extends Required<Pick<T, P>> ? T[P] : T[P] | undefined
}
type ValidateItemOptions = {
value: unknown
customValidationConcurrencyLimiter?: ConcurrencyLimiter
} & ExplicitUndefined<ValidationContext>
export function validateItem(opts: ValidateItemOptions): Promise<ValidationMarker[]> {
return lastValueFrom(validateItemObservable(opts))
}
function validateItemObservable({
value,
type,
path = [],
parent,
customValidationConcurrencyLimiter,
environment,
...restOfContext
}: ValidateItemOptions): Observable<ValidationMarker[]> {
// Note: this validator is added here because it's conditional based on the
// environment.
const addUnknownFieldsValidator = (rule: Rule) => {
if (
// if the schema type is an object type
type?.jsonType === 'object' &&
// and if somewhere in it's type chain, it inherits from object or document
getTypeChain(type).find((t) => ['object', 'document', 'file', 'image'].includes(t.name)) &&
// and the environment is not the studio
environment !== 'studio'
) {
// then add the validator for unknown fields
return rule.custom(unknownFieldsValidator(type), {bypassConcurrencyLimit: true}).warning()
}
// otherwise, leave it unchanged
return rule
}
const rules = normalizeValidationRules(type)
// run validation for the current value
const selfChecks = rules.map(addUnknownFieldsValidator).map((rule) =>
defer(() =>
rule.validate(value, {
...restOfContext,
environment,
parent,
path,
type,
__internal: {customValidationConcurrencyLimiter},
}),
),
)
// run validation for nested values (conditionally)
let nestedChecks: Array<Observable<ValidationMarker[]>> = []
const selfIsRequired = rules.some((rule) => rule.isRequired())
const shouldRunNestedObjectValidation =
// run nested validation for objects
type?.jsonType === 'object' &&
// if the value is truthy
(!!value || // or
// (the value is null or undefined) and the top-level value is required
((value === null || value === undefined) && selfIsRequired))
if (shouldRunNestedObjectValidation) {
const fieldTypes = type.fields.reduce<Record<string, SchemaType>>((acc, field) => {
acc[field.name] = field.type
return acc
}, {})
// Validation for rules set at the object level with `Rule.fields({/* ... */})`
nestedChecks = nestedChecks.concat(
rules
.map((rule) => rule._fieldRules)
.filter(isNonNullable)
.flatMap((fieldResults) => Object.entries(fieldResults))
.flatMap(([name, validation]) => {
const fieldType = fieldTypes[name]
return normalizeValidationRules({...fieldType, validation})
.map(addUnknownFieldsValidator)
.map((subRule) => {
const nestedValue = isRecord(value) ? value[name] : undefined
return defer(() =>
subRule.validate(nestedValue, {
...restOfContext,
parent: value,
path: path.concat(name),
type: fieldType,
environment,
__internal: {customValidationConcurrencyLimiter},
}),
)
})
}),
)
// Validation from each field's schema `validation: Rule => {/* ... */}` function
nestedChecks = nestedChecks.concat(
type.fields.map((field) =>
validateItemObservable({
...restOfContext,
parent: value,
value: isRecord(value) ? value[field.name] : undefined,
path: path.concat(field.name),
type: field.type,
environment,
customValidationConcurrencyLimiter,
}),
),
)
}
// note: unlike objects, arrays should not run nested validation for undefined
// values because we won't have a valid path to put a marker (i.e. missing the
// key or index in the path) and the downstream form builder won't have a
// valid target component
const shouldRunNestedValidationForArrays = type?.jsonType === 'array' && Array.isArray(value)
if (shouldRunNestedValidationForArrays) {
nestedChecks = nestedChecks.concat(
value.map((item, index) =>
validateItemObservable({
...restOfContext,
parent: value,
value: item,
path: path.concat(isKeyedObject(item) ? {_key: item._key} : index),
type: resolveTypeForArrayItem(item, type.of),
environment,
customValidationConcurrencyLimiter,
}),
),
)
}
return defer(() => merge([...selfChecks, ...nestedChecks])).pipe(
mergeMap((validateNode) => concat(idle(), validateNode), 40),
mergeAll(),
toArray(),
map(flatten),
map((results) => {
// run `uniqBy` if `_fieldRules` are present because they can
// cause repeat markers
if (rules.some((rule) => rule._fieldRules)) {
return uniqBy(results, (rule) => JSON.stringify(rule))
}
return results
}),
)
}
function idle(timeout?: number): Observable<never> {
return new Observable<never>((observer) => {
const handle = requestIdleCallback(
() => {
observer.complete()
},
timeout ? {timeout} : undefined,
)
return () => cancelIdleCallback(handle)
})
}