UNPKG

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

448 lines (377 loc) 14.1 kB
import { type CustomValidator, type FieldRules, type LocalizedValidationMessages, type Rule as IRule, type RuleClass, type RuleSpec, type RuleSpecConstraint, type RuleTypeConstraint, type SchemaType, type ValidationMarker, type Validator, } from '@sanity/types' import {cloneDeep, get} from 'lodash' import {convertToValidationMarker} from './util/convertToValidationMarker' import {escapeRegex} from './util/escapeRegex' import {isLocalizedMessages, localizeMessage} from './util/localizeMessage' import {pathToString} from './util/pathToString' import {arrayValidators} from './validators/arrayValidator' import {booleanValidators} from './validators/booleanValidator' import {dateValidators} from './validators/dateValidator' import {genericValidators} from './validators/genericValidator' import {numberValidators} from './validators/numberValidator' import {objectValidators} from './validators/objectValidator' import {stringValidators} from './validators/stringValidator' const typeValidators = { Boolean: booleanValidators, Number: numberValidators, String: stringValidators, Array: arrayValidators, Object: objectValidators, Date: dateValidators, } const getBaseType = (type: SchemaType | undefined): SchemaType | undefined => { return type && type.type ? getBaseType(type.type) : type } const isFieldRef = (constraint: unknown): constraint is {type: symbol; path: string | string[]} => { if (typeof constraint !== 'object' || !constraint) return false return (constraint as Record<string, unknown>).type === Rule.FIELD_REF } const EMPTY_ARRAY: unknown[] = [] const FIELD_REF = Symbol('FIELD_REF') const ruleConstraintTypes: RuleTypeConstraint[] = [ 'Array', 'Boolean', 'Date', 'Number', 'Object', 'String', ] // Note: `RuleClass` and `Rule` are split to fit the current `@sanity/types` // setup. Classes are a bit weird in the `@sanity/types` package because classes // create an actual javascript class while simultaneously creating a type // definition. // // This implicitly creates two types: // 1. the instance type — `Rule` and // 2. the static/class type - `RuleClass` // // The `RuleClass` type contains the static methods and the `Rule` instance // contains the instance methods. // // This package exports the RuleClass as a value without implicitly exporting // an instance definition. This should help reminder downstream users to import // from the `@sanity/types` package. export const Rule: RuleClass = class Rule implements IRule { static readonly FIELD_REF = FIELD_REF static array = (def?: SchemaType): Rule => new Rule(def).type('Array') static object = (def?: SchemaType): Rule => new Rule(def).type('Object') static string = (def?: SchemaType): Rule => new Rule(def).type('String') static number = (def?: SchemaType): Rule => new Rule(def).type('Number') static boolean = (def?: SchemaType): Rule => new Rule(def).type('Boolean') static dateTime = (def?: SchemaType): Rule => new Rule(def).type('Date') static valueOfField = (path: string | string[]): {type: symbol; path: string | string[]} => ({ type: FIELD_REF, path, }) _type: RuleTypeConstraint | undefined = undefined _level: 'error' | 'warning' | 'info' | undefined = undefined _required: 'required' | 'optional' | undefined = undefined _typeDef: SchemaType | undefined = undefined _message: string | LocalizedValidationMessages | undefined = undefined _rules: RuleSpec[] = [] _fieldRules: FieldRules | undefined = undefined constructor(typeDef?: SchemaType) { this._typeDef = typeDef this.reset() } private _mergeRequired(next: Rule) { if (this._required === 'required' || next._required === 'required') return 'required' if (this._required === 'optional' || next._required === 'optional') return 'optional' return undefined } // Alias to static method, since we often have access to an _instance_ of a rule but not the actual Rule class valueOfField = Rule.valueOfField.bind(Rule) error(message?: string | LocalizedValidationMessages): Rule { const rule = this.clone() rule._level = 'error' rule._message = message || undefined return rule } warning(message?: string | LocalizedValidationMessages): Rule { const rule = this.clone() rule._level = 'warning' rule._message = message || undefined return rule } info(message?: string | LocalizedValidationMessages): Rule { const rule = this.clone() rule._level = 'info' rule._message = message || undefined return rule } reset(): this { this._type = this._type || undefined this._rules = (this._rules || []).filter((rule) => rule.flag === 'type') this._message = undefined this._required = undefined this._level = 'error' this._fieldRules = undefined return this } isRequired(): boolean { return this._required === 'required' } clone(): Rule { const rule = new Rule() rule._type = this._type rule._message = this._message rule._required = this._required rule._rules = cloneDeep(this._rules) rule._level = this._level rule._fieldRules = this._fieldRules rule._typeDef = this._typeDef return rule } cloneWithRules(rules: RuleSpec[]): Rule { const rule = this.clone() const newRules = new Set() rules.forEach((curr) => { if (curr.flag === 'type') { rule._type = curr.constraint } newRules.add(curr.flag) }) rule._rules = rule._rules .filter((curr) => { const disallowDuplicate = ['type', 'uri', 'email'].includes(curr.flag) const isDuplicate = newRules.has(curr.flag) return !(disallowDuplicate && isDuplicate) }) .concat(rules) return rule } merge(rule: Rule): Rule { if (this._type && rule._type && this._type !== rule._type) { throw new Error('merge() failed: conflicting types') } const newRule = this.cloneWithRules(rule._rules) newRule._type = this._type || rule._type newRule._message = this._message || rule._message newRule._required = this._mergeRequired(rule) newRule._level = this._level === 'error' ? rule._level : this._level return newRule } // Validation flag setters type(targetType: RuleTypeConstraint | Lowercase<RuleTypeConstraint>): Rule { const type = `${targetType.slice(0, 1).toUpperCase()}${targetType.slice(1)}` as Capitalize< typeof targetType > if (!ruleConstraintTypes.includes(type)) { throw new Error(`Unknown type "${targetType}"`) } const rule = this.cloneWithRules([{flag: 'type', constraint: type}]) rule._type = type return rule } all(children: Rule[]): Rule { return this.cloneWithRules([{flag: 'all', constraint: children}]) } either(children: Rule[]): Rule { return this.cloneWithRules([{flag: 'either', constraint: children}]) } // Shared rules optional(): Rule { const rule = this.cloneWithRules([{flag: 'presence', constraint: 'optional'}]) rule._required = 'optional' return rule } required(): Rule { const rule = this.cloneWithRules([{flag: 'presence', constraint: 'required'}]) rule._required = 'required' return rule } custom<T = unknown>( fn: CustomValidator<T>, options: {bypassConcurrencyLimit?: boolean} = {}, ): Rule { if (options.bypassConcurrencyLimit) { Object.assign(fn, {bypassConcurrencyLimit: true}) } return this.cloneWithRules([{flag: 'custom', constraint: fn as CustomValidator}]) } min(len: number | string): Rule { return this.cloneWithRules([{flag: 'min', constraint: len}]) } max(len: number | string): Rule { return this.cloneWithRules([{flag: 'max', constraint: len}]) } length(len: number): Rule { return this.cloneWithRules([{flag: 'length', constraint: len}]) } valid(value: unknown | unknown[]): Rule { const values = Array.isArray(value) ? value : [value] return this.cloneWithRules([{flag: 'valid', constraint: values}]) } // Numbers only integer(): Rule { return this.cloneWithRules([{flag: 'integer'}]) } precision(limit: number): Rule { return this.cloneWithRules([{flag: 'precision', constraint: limit}]) } positive(): Rule { return this.cloneWithRules([{flag: 'min', constraint: 0}]) } negative(): Rule { return this.cloneWithRules([{flag: 'lessThan', constraint: 0}]) } greaterThan(num: number): Rule { return this.cloneWithRules([{flag: 'greaterThan', constraint: num}]) } lessThan(num: number): Rule { return this.cloneWithRules([{flag: 'lessThan', constraint: num}]) } // String only uppercase(): Rule { return this.cloneWithRules([{flag: 'stringCasing', constraint: 'uppercase'}]) } lowercase(): Rule { return this.cloneWithRules([{flag: 'stringCasing', constraint: 'lowercase'}]) } regex(pattern: RegExp, name: string, options: {name?: string; invert?: boolean}): Rule regex(pattern: RegExp, options: {name?: string; invert?: boolean}): Rule regex(pattern: RegExp, name: string): Rule regex(pattern: RegExp): Rule regex( pattern: RegExp, a?: string | {name?: string; invert?: boolean}, b?: {name?: string; invert?: boolean}, ): Rule { const name = typeof a === 'string' ? a : a?.name ?? b?.name const invert = typeof a === 'string' ? false : a?.invert ?? b?.invert const constraint: RuleSpecConstraint<'regex'> = { pattern, name, invert: invert || false, } return this.cloneWithRules([{flag: 'regex', constraint}]) } email(): Rule { return this.cloneWithRules([{flag: 'email'}]) } uri(opts?: { scheme?: (string | RegExp) | Array<string | RegExp> allowRelative?: boolean relativeOnly?: boolean allowCredentials?: boolean }): Rule { const optsScheme = opts?.scheme || ['http', 'https'] const schemes = Array.isArray(optsScheme) ? optsScheme : [optsScheme] if (!schemes.length) { throw new Error('scheme must have at least 1 scheme specified') } const constraint: RuleSpecConstraint<'uri'> = { options: { scheme: schemes.map((scheme) => { if (!(scheme instanceof RegExp) && typeof scheme !== 'string') { throw new Error('scheme must be a RegExp or a String') } return typeof scheme === 'string' ? new RegExp(`^${escapeRegex(scheme)}$`) : scheme }), allowRelative: opts?.allowRelative || false, relativeOnly: opts?.relativeOnly || false, allowCredentials: opts?.allowCredentials || false, }, } return this.cloneWithRules([{flag: 'uri', constraint}]) } // Array only unique(): Rule { return this.cloneWithRules([{flag: 'unique'}]) } // Objects only reference(): Rule { return this.cloneWithRules([{flag: 'reference'}]) } fields(rules: FieldRules): Rule { if (this._type !== 'Object') { throw new Error('fields() can only be called on an object type') } const rule = this.cloneWithRules([]) rule._fieldRules = rules return rule } assetRequired(): Rule { const base = getBaseType(this._typeDef) let assetType: 'asset' | 'image' | 'file' if (base && ['image', 'file'].includes(base.name)) { assetType = base.name === 'image' ? 'image' : 'file' } else { assetType = 'asset' } return this.cloneWithRules([{flag: 'assetRequired', constraint: {assetType}}]) } async validate( value: unknown, {__internal = {}, ...context}: Parameters<IRule['validate']>[1], ): Promise<ValidationMarker[]> { const {customValidationConcurrencyLimiter} = __internal const valueIsEmpty = value === null || value === undefined // Short-circuit on optional, empty fields if (valueIsEmpty && this._required === 'optional') { return EMPTY_ARRAY as ValidationMarker[] } const rules = // Run only the _custom_ functions if the rule is not set to required or optional this._required === undefined && valueIsEmpty ? this._rules.filter((curr) => curr.flag === 'custom') : this._rules const validators = (this._type && typeValidators[this._type]) || genericValidators const results = await Promise.all( rules.map(async (curr) => { if (curr.flag === undefined) { throw new Error('Invalid rule, did not contain "flag"-property') } const validator: Validator | undefined = validators[curr.flag] if (!validator) { const forType = this._type ? `type "${this._type}"` : 'rule without declared type' throw new Error(`Validator for flag "${curr.flag}" not found for ${forType}`) } let specConstraint = 'constraint' in curr ? curr.constraint : null if (isFieldRef(specConstraint)) { specConstraint = get(context.parent, specConstraint.path) } if ( curr.flag === 'custom' && customValidationConcurrencyLimiter && !(specConstraint as CustomValidator)?.bypassConcurrencyLimit ) { const customValidator = specConstraint as CustomValidator specConstraint = async (...args: Parameters<CustomValidator>) => { await customValidationConcurrencyLimiter.ready() try { return await customValidator(...args) } finally { customValidationConcurrencyLimiter.release() } } } const message = isLocalizedMessages(this._message) ? localizeMessage(this._message, context.i18n) : this._message try { const result = await validator(specConstraint, value, message, context) return convertToValidationMarker(result, this._level, context) } catch (err) { const errorMessage = `${pathToString( context.path, )}: Exception occurred while validating value: ${err.message}` return convertToValidationMarker({message: errorMessage}, 'error', context) } }), ) return results.flat() } }