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
740 lines (623 loc) • 21.9 kB
text/typescript
import {generateHelpUrl} from '@sanity/generate-help-url'
import {Schema} from '@sanity/schema'
import {
type ArraySchemaType,
type CrossDatasetReferenceSchemaType,
type IntrinsicTypeName,
isDeprecationConfiguration,
type ObjectField,
type ObjectFieldType,
type ObjectSchemaType,
type ReferenceSchemaType,
type Schema as CompiledSchema,
type SchemaType,
} from '@sanity/types'
import {startCase, uniqBy} from 'lodash'
import oneline from 'oneline'
import * as helpUrls from './helpUrls'
import {SchemaError} from './SchemaError'
import {
type ApiSpecification,
type ConvertedFieldDefinition,
type ConvertedInterface,
type ConvertedType,
type ConvertedUnion,
type Deprecation,
} from './types'
const skipTypes = ['document', 'reference']
const allowedJsonTypes = ['object', 'array']
const disallowedCustomizedMembers = ['object', 'array', 'image', 'file', 'block']
const disabledBlockFields = ['markDefs']
const scalars = ['string', 'number', 'boolean']
/**
* Data required elsewhere in the API specification generation process, but that should not be
* included in the generated API specification.
*/
export const internal = Symbol('internal')
function getBaseType(baseSchema: CompiledSchema, typeName: IntrinsicTypeName): SchemaType {
if (typeName === 'crossDatasetReference') {
return Schema.compile({
types: (baseSchema._original?.types || []).concat([
{
name: `__placeholder__`,
type: 'crossDatasetReference',
// Just needs _something_ to refer to, doesn't matter what
to: [{type: 'sanity.imageAsset'}],
},
]),
}).get('__placeholder__')
}
return Schema.compile({
types: (baseSchema._original?.types || []).concat([
{name: `__placeholder__`, type: typeName, options: {hotspot: true}},
]),
}).get('__placeholder__')
}
function getTypeName(str: string): string {
const name = startCase(str).replace(/\s+/g, '')
return name === 'Number' ? 'Float' : name
}
function isBaseType(type: SchemaType): boolean {
return (
type.name !== type.jsonType &&
allowedJsonTypes.includes(type.jsonType) &&
!skipTypes.includes(type.name) &&
!isReference(type)
)
}
function isBlockType(typeDef: SchemaType | ObjectField): boolean {
if (typeDef.name === 'block') {
return true
}
if (typeDef.type) {
return isBlockType(typeDef.type)
}
return false
}
function hasBlockParent(typeDef: SchemaType): boolean {
if (typeDef.type && typeDef.type.name === 'block' && !typeDef.type.type) {
return true
}
return Boolean(typeDef.type && hasBlockParent(typeDef.type))
}
function isArrayOfBlocks(typeDef: SchemaType | ObjectField): boolean {
const type = typeDef.type || typeDef
if (!('jsonType' in type) || type.jsonType !== 'array') {
return false
}
return (type.of || []).some(hasBlockParent)
}
function isType(typeDef: SchemaType | ObjectField | ObjectFieldType, typeName: string): boolean {
let type: SchemaType | ObjectField | ObjectFieldType | undefined = typeDef
while (type) {
if (type.name === typeName || (type.type && type.type.name === typeName)) {
return true
}
type = type.type
}
return false
}
function isReference(
typeDef: SchemaType | ObjectField | ObjectFieldType,
): typeDef is ReferenceSchemaType {
return isType(typeDef, 'reference')
}
function isCrossDatasetReference(
typeDef: SchemaType | ObjectField | ObjectFieldType | CrossDatasetReferenceSchemaType,
) {
return isType(typeDef, 'crossDatasetReference')
}
function getCrossDatasetReferenceMetadata(
typeDef: SchemaType | ObjectField | ObjectFieldType | CrossDatasetReferenceSchemaType,
) {
if (!isCrossDatasetReference(typeDef)) return undefined
function getTypeNames(
type: SchemaType | ObjectField | ObjectFieldType | CrossDatasetReferenceSchemaType | undefined,
) {
if (!type) return undefined
if (!('to' in type)) return getTypeNames(type.type)
return type.to.map((t) => t.type).filter((t): t is string => typeof t === 'string')
}
function getDataset(
type: SchemaType | ObjectField | ObjectFieldType | CrossDatasetReferenceSchemaType | undefined,
) {
if (!type) return undefined
if ('dataset' in type && typeof type.dataset === 'string') return type.dataset
if (type.type) return getDataset(type.type)
return undefined
}
const typeNames = getTypeNames(typeDef)
if (!typeNames) return undefined
const dataset = getDataset(typeDef)
if (typeof dataset !== 'string') return undefined
return {typeNames, dataset}
}
export function extractFromSanitySchema(
sanitySchema: CompiledSchema,
extractOptions: {nonNullDocumentFields?: boolean; withUnionCache?: boolean} = {},
): ApiSpecification {
const {nonNullDocumentFields, withUnionCache} = extractOptions
const unionRecursionGuards = new Set<string>()
const unionDefinitionCache = new Map<string, any>()
const hasErrors =
sanitySchema._validation &&
sanitySchema._validation.some((group) =>
group.problems.some((problem) => problem.severity === 'error'),
)
if (hasErrors && Array.isArray(sanitySchema._validation)) {
throw new SchemaError(sanitySchema._validation)
}
const sanityTypes = sanitySchema._original?.types || []
const typeNames = sanitySchema.getTypeNames()
const unionTypes: ConvertedUnion[] = []
const types: ConvertedType[] = []
for (const typeName of typeNames) {
const schemaType = sanitySchema.get(typeName)
if (schemaType === undefined) {
continue
}
if (!isBaseType(schemaType)) {
continue
}
const convertedType = convertType(schemaType)
types.push(convertedType)
}
const withUnions = [...types, ...unionTypes]
return {types: withUnions, interfaces: [getDocumentInterfaceDefinition()]}
function isTopLevelType(typeName: string): boolean {
return typeNames.includes(typeName)
}
function mapFieldType(field: SchemaType | ObjectField | ObjectFieldType): string {
if (!field.type) {
throw new Error('Field has no type!')
}
const jsonType = 'jsonType' in field ? field.jsonType : ''
const isScalar = scalars.includes(jsonType)
if (isScalar && jsonType === 'number') {
return hasValidationFlag(field, 'integer') ? 'Int' : 'Float'
} else if (isScalar) {
return getTypeName(jsonType)
}
const type = field.type.type || field.type
// In the case of nested scalars, recurse (markdown -> longText -> text -> string)
if (type.type) {
return mapFieldType(type)
}
switch (type.name) {
case 'number':
return hasValidationFlag(field, 'integer') ? 'Int' : 'Float'
default:
return getTypeName(type.name)
}
}
function isArrayType(type: SchemaType | ObjectField): type is ArraySchemaType {
return Boolean(
('jsonType' in type && type.jsonType === 'array') ||
(type.type && type.type.jsonType === 'array'),
)
}
function _convertType(
type: SchemaType | ObjectField,
parent: string,
options: {isField?: boolean},
): ConvertedType {
let name: string | undefined
if (type.type) {
name = type.type.name
} else if ('jsonType' in type) {
name = type.jsonType
}
if (isReference(type)) {
return getReferenceDefinition(type, parent)
}
if (isArrayType(type)) {
return getArrayDefinition(type, parent, options)
}
if (name === 'document') {
return getDocumentDefinition(type as ObjectSchemaType)
}
if (name === 'block' || name === 'object') {
return getObjectDefinition(type, parent)
}
if (hasFields(type)) {
return getObjectDefinition(type, parent)
}
return {
type: mapFieldType(type),
description: getDescription(type),
} as any
}
function convertType(
type: SchemaType | ObjectField,
parent?: string,
props: {fieldName?: string} & Partial<Deprecation> = {},
): ConvertedType {
const mapped = _convertType(type, parent || '', {isField: Boolean(props.fieldName)})
const gqlName = props.fieldName || mapped.name
const originalName = type.name
const original = gqlName === originalName ? {} : {originalName: originalName}
const crossDatasetReferenceMetadata = getCrossDatasetReferenceMetadata(type)
return {
...getDeprecation(type.type),
...props,
...mapped,
...original,
...(crossDatasetReferenceMetadata && {crossDatasetReferenceMetadata}),
}
}
function isField(def: SchemaType | ObjectField): def is ObjectField {
return !('jsonType' in def) || !def.jsonType
}
// eslint-disable-next-line complexity
function getObjectDefinition(def: SchemaType | ObjectField, parent?: string): ConvertedType {
const isInline = isField(def)
const isDocument = def.type ? def.type.name === 'document' : false
const actualType = isInline ? def.type : def
if (typeNeedsHoisting(actualType)) {
throw createLiftTypeError(def.name, parent || '', actualType.name)
}
if (isInline && parent && def.type.name === 'object') {
throw createLiftTypeError(def.name, parent)
}
if (parent && def.type && isTopLevelType(def.type.name)) {
return {type: getTypeName(def.type.name)} as any
}
const name = `${parent || ''}${getTypeName(def.name)}`
const fields = collectFields(def)
const firstUnprefixed = Math.max(
0,
fields.findIndex((field) => field.name[0] !== '_'),
)
const keyField = createStringField('_key')
fields.splice(firstUnprefixed, 0, keyField)
if (!isDocument) {
fields.splice(firstUnprefixed + 1, 0, createStringField('_type'))
}
const objectIsBlock = isBlockType(def)
const objectFields = objectIsBlock
? fields.filter((field) => !disabledBlockFields.includes(field.name))
: fields
return {
kind: 'Type',
name,
type: 'Object',
description: getDescription(def),
fields: objectFields.map((field) =>
isArrayOfBlocks(field)
? buildRawField(field, name)
: (convertType(field, name, {
fieldName: field.name,
...getDeprecation(def),
}) as any),
),
[internal]: {
...getDeprecation(def),
},
}
}
function buildRawField(field: ObjectField, parentName: string) {
return {
...convertType(field, parentName, {fieldName: `${field.name}Raw`}),
type: 'JSON',
isRawAlias: true,
}
}
function createStringField(name: string): ObjectField {
return {
name,
type: {
jsonType: 'string',
name: 'string',
type: {name: 'string', type: undefined, jsonType: 'string'},
},
}
}
function collectFields(def: SchemaType | ObjectField) {
const fields = gatherAllFields(def)
if (fields.length > 0) {
return fields
}
const extended = getBaseType(sanitySchema, def.name as IntrinsicTypeName)
return gatherAllFields(extended)
}
function getReferenceDefinition(def: SchemaType, parent: string): any {
const base = {description: getDescription(def), isReference: true}
const candidates = arrayify(gatherAllReferenceCandidates(def))
if (candidates.length === 0) {
throw new Error('No candidates for reference')
}
if (candidates.length === 1) {
return {type: getTypeName(candidates[0].type.name), ...base}
}
const unionDefinition = getUnionDefinition(candidates, def, {grandParent: parent})
return {...unionDefinition, ...base}
}
function getArrayDefinition(
def: ArraySchemaType,
parent: string,
options: {isField?: boolean} = {},
): any {
const base = {description: getDescription(def), kind: 'List'}
const name = !options.isField && def.name ? {name: getTypeName(def.name)} : {}
const candidates = def.type?.type && 'of' in def.type ? arrayify(def.type.of) : def.of
return candidates.length === 1
? {
children: getArrayChildDefinition(candidates[0], def),
...base,
...name,
}
: {
children: getUnionDefinition(candidates, def, {grandParent: parent}),
...base,
...name,
}
}
function getArrayChildDefinition(child: SchemaType, arrayDef: SchemaType) {
if (typeNeedsHoisting(child)) {
// Seems to be inline? Should be hoisted?
throw createLiftTypeError(child.name, arrayDef.name)
}
if (isReference(child)) {
return getReferenceDefinition(child, arrayDef.name)
}
// In the case of nested scalars, recurse (markdown -> longText -> text -> string)
if (scalars.includes(child.jsonType) && !scalars.includes(child.name)) {
return {type: mapFieldType(child)}
}
return {type: getTypeName(child.name)}
}
function typeNeedsHoisting(type: SchemaType & {isCustomized?: boolean}): boolean {
if (type.name === 'object') {
return true
}
if (type.jsonType === 'object' && !isTopLevelType(type.name)) {
return true
}
if (type.isCustomized && !isTopLevelType(type.name)) {
return true
}
if (type.isCustomized && disallowedCustomizedMembers.includes(type.name)) {
return true
}
return false
}
function getUnionDefinition(
candidates: ObjectSchemaType[],
parent: SchemaType,
options: {grandParent?: string} = {},
) {
if (candidates.length < 2) {
throw new Error('Not enough candidates for a union type')
}
// #1482: When creating union definition do not get caught in recursion loop
// for types that reference themselves
const guardPathName = `${typeof parent === 'object' ? parent.name : parent}`
if (unionRecursionGuards.has(guardPathName)) {
return {}
}
const unionCacheKey = `${options.grandParent}-${guardPathName}-${candidates
.map((c) => c.type?.name)
.join('-')}`
if (withUnionCache && unionDefinitionCache.has(unionCacheKey)) {
return unionDefinitionCache.get(unionCacheKey)
}
try {
unionRecursionGuards.add(guardPathName)
candidates.forEach((def, i) => {
if (typeNeedsHoisting(def)) {
throw createLiftTypeArrayError(
i,
parent.name,
def.type ? def.type.name : def.name,
options.grandParent,
)
}
})
const converted = candidates.map((def) => convertType(def))
const getName = (def: {type: string | {name: string}}): string =>
typeof def.type === 'string' ? def.type : def.type.name
// We might end up with union types being returned - these needs to be flattened
// so that an ImageOr(PersonOrPet) becomes ImageOrPersonOrPet
const flattened = converted.reduce(
(acc, candidate) => {
const union = unionTypes.find((item) => item.name === candidate.type)
return union
? acc.concat(union.types.map((type) => ({type, isReference: candidate.isReference})))
: acc.concat(candidate)
},
[] as {name?: string; type: string | {name: string}; isReference?: boolean}[],
)
let allCandidatesAreDocuments = true
const refs: (string | {name: string})[] = []
const inlineObjs: string[] = []
const allTypeNames: string[] = []
for (const def of flattened) {
if (def.isReference) {
refs.push(def.type)
}
if (!isReference) {
inlineObjs.push(def.name || '')
}
const typeName = typeof def.type === 'string' ? def.type : def.type.name
// Here we remove duplicates, as they might appear twice due to in-line usage of types as well as references
if (def.name || def.type) {
allTypeNames.push(def.isReference ? typeName : def.name || '')
}
const typeDef = sanityTypes.find((type) => type.name === getName(def))
if (!typeDef || typeDef.type !== 'document') {
allCandidatesAreDocuments = false
}
}
const interfaces = allCandidatesAreDocuments ? ['Document'] : undefined
const possibleTypes = [...new Set(allTypeNames)].sort()
if (possibleTypes.length < 2) {
throw new Error(`Not enough types for a union type. Parent: ${parent.name}`)
}
const name = possibleTypes.join('Or')
if (!unionTypes.some((item) => item.name === name)) {
unionTypes.push({
kind: 'Union',
name,
types: possibleTypes,
interfaces,
})
}
const references = refs.length > 0 ? refs : undefined
const inlineObjects = inlineObjs.length > 0 ? inlineObjs : undefined
const unionDefinition = isReference(parent)
? {type: name, references}
: {type: name, references, inlineObjects}
unionDefinitionCache.set(unionCacheKey, unionDefinition)
return unionDefinition
} finally {
unionRecursionGuards.delete(guardPathName)
}
}
function getDocumentDefinition(def: ObjectSchemaType) {
const objectDef = getObjectDefinition(def)
const fields = getDocumentInterfaceFields(def).concat(objectDef.fields)
return {...objectDef, fields, interfaces: ['Document']}
}
function getDocumentInterfaceDefinition(): ConvertedInterface {
return {
kind: 'Interface',
name: 'Document',
description: 'A Sanity document',
fields: getDocumentInterfaceFields(),
}
}
function getDocumentInterfaceFields(type?: ObjectSchemaType): ConvertedFieldDefinition[] {
const isNullable = typeof nonNullDocumentFields === 'boolean' ? !nonNullDocumentFields : true
return [
{
fieldName: '_id',
type: 'ID',
isNullable,
description: 'Document ID',
...getDeprecation(type),
},
{
fieldName: '_type',
type: 'String',
isNullable,
description: 'Document type',
...getDeprecation(type),
},
{
fieldName: '_createdAt',
type: 'Datetime',
isNullable,
description: 'Date the document was created',
...getDeprecation(type),
},
{
fieldName: '_updatedAt',
type: 'Datetime',
isNullable,
description: 'Date the document was last modified',
...getDeprecation(type),
},
{
fieldName: '_rev',
type: 'String',
isNullable,
description: 'Current document revision',
...getDeprecation(type),
},
]
}
function arrayify(thing: unknown) {
if (Array.isArray(thing)) {
return thing
}
return thing === null || typeof thing === 'undefined' ? [] : [thing]
}
function hasValidationFlag(
field: SchemaType | ObjectField | ObjectFieldType,
flag: string,
): boolean {
return (
'validation' in field &&
Array.isArray(field.validation) &&
field.validation.some(
(rule) => rule && '_rules' in rule && rule._rules.some((item) => item.flag === flag),
)
)
}
function getDescription(type: SchemaType | ObjectField): string | undefined {
const description = type.type && type.type.description
return typeof description === 'string' ? description : undefined
}
function gatherAllReferenceCandidates(type: SchemaType): ObjectSchemaType[] {
const allFields = gatherReferenceCandidates(type)
return uniqBy(allFields, 'name')
}
function gatherReferenceCandidates(type: SchemaType): ObjectSchemaType[] {
const refTo = 'to' in type ? type.to : []
return 'type' in type && type.type ? [...gatherReferenceCandidates(type.type), ...refTo] : refTo
}
function gatherAllFields(type: SchemaType | ObjectField) {
const allFields = gatherFields(type)
return uniqBy(allFields, 'name')
}
function gatherFields(type: SchemaType | ObjectField): ObjectField[] {
if ('fields' in type) {
return type.type ? gatherFields(type.type).concat(type.fields) : type.fields
}
return []
}
function hasFieldsLikeShape(type: unknown): type is {fields: unknown} {
return typeof type === 'object' && type !== null && 'fields' in type
}
function hasArrayOfFields(type: unknown): type is {fields: ObjectField[]} {
return hasFieldsLikeShape(type) && Array.isArray(type.fields)
}
function hasFields(type: SchemaType | ObjectField): boolean {
if (hasArrayOfFields(type)) {
return gatherAllFields(type).length > 0
}
return 'type' in type && type.type ? hasFields(type.type) : false
}
}
function createLiftTypeArrayError(
index: number,
parent: string,
inlineType = 'object',
grandParent = '',
) {
const helpUrl = generateHelpUrl(helpUrls.SCHEMA_LIFT_ANONYMOUS_OBJECT_TYPE)
const context = [grandParent, parent].filter(Boolean).join('/')
return new HelpfulError(
oneline`
Encountered anonymous inline ${inlineType} at index ${index} for type/field ${context}.
To use this type with GraphQL you will need to create a top-level schema type for it.
See ${helpUrl}`,
helpUrl,
)
}
function createLiftTypeError(typeName: string, parent: string, inlineType = 'object') {
const helpUrl = generateHelpUrl(helpUrls.SCHEMA_LIFT_ANONYMOUS_OBJECT_TYPE)
return new HelpfulError(
oneline`
Encountered anonymous inline ${inlineType} "${typeName}" for field/type "${parent}".
To use this field with GraphQL you will need to create a top-level schema type for it.
See ${helpUrl}`,
helpUrl,
)
}
class HelpfulError extends Error {
helpUrl?: string
constructor(message: string, helpUrl?: string) {
super(message)
this.helpUrl = helpUrl
}
}
function getDeprecation(
type?: SchemaType | ObjectFieldType<SchemaType> | ObjectField<SchemaType>,
): Partial<Deprecation> {
return isDeprecationConfiguration(type)
? {
deprecationReason: type.deprecated.reason,
}
: {}
}