UNPKG

@kubb/plugin-oas

Version:
955 lines (819 loc) • 30.7 kB
import { BaseGenerator, type FileMetaBase } from '@kubb/core' import transformers, { pascalCase } from '@kubb/core/transformers' import { getUniqueName } from '@kubb/core/utils' import { isNullable, isReference } from '@kubb/oas' import { isDeepEqual, isNumber, uniqueWith } from 'remeda' import { isKeyword, schemaKeywords } from './SchemaMapper.ts' import { getSchemaFactory } from './utils/getSchemaFactory.ts' import { getSchemas } from './utils/getSchemas.ts' import type { Plugin, PluginFactoryOptions, PluginManager, ResolveNameParams } from '@kubb/core' import type * as KubbFile from '@kubb/fs/types' import type { Oas, OpenAPIV3, SchemaObject, contentType } from '@kubb/oas' import type { Schema, SchemaKeywordMapper } from './SchemaMapper.ts' import type { Generator } from './generator.tsx' import type { OperationSchema, Override, Refs } from './types.ts' export type GetSchemaGeneratorOptions<T extends SchemaGenerator<any, any, any>> = T extends SchemaGenerator<infer Options, any, any> ? Options : never export type SchemaMethodResult<TFileMeta extends FileMetaBase> = Promise<KubbFile.File<TFileMeta> | Array<KubbFile.File<TFileMeta>> | null> type Context<TOptions, TPluginOptions extends PluginFactoryOptions> = { oas: Oas pluginManager: PluginManager /** * Current plugin */ plugin: Plugin<TPluginOptions> mode: KubbFile.Mode include?: Array<'schemas' | 'responses' | 'requestBodies'> override: Array<Override<TOptions>> | undefined contentType?: contentType output?: string } export type SchemaGeneratorOptions = { dateType: false | 'string' | 'stringOffset' | 'stringLocal' | 'date' unknownType: 'any' | 'unknown' | 'void' enumType?: 'enum' | 'asConst' | 'asPascalConst' | 'constEnum' | 'literal' enumSuffix?: string usedEnumNames?: Record<string, number> mapper?: Record<string, string> typed?: boolean transformers: { /** * Customize the names based on the type that is provided by the plugin. */ name?: (name: ResolveNameParams['name'], type?: ResolveNameParams['type']) => string /** * Receive schema and name(propertName) and return FakerMeta array * TODO TODO add docs * @beta */ schema?: (schemaProps: SchemaProps, defaultSchemas: Schema[]) => Schema[] | undefined } } export type SchemaGeneratorBuildOptions = Omit<OperationSchema, 'name' | 'schema'> type SchemaProps = { schema?: SchemaObject name?: string parentName?: string } export class SchemaGenerator< TOptions extends SchemaGeneratorOptions = SchemaGeneratorOptions, TPluginOptions extends PluginFactoryOptions = PluginFactoryOptions, TFileMeta extends FileMetaBase = FileMetaBase, > extends BaseGenerator<TOptions, Context<TOptions, TPluginOptions>> { // Collect the types of all referenced schemas, so we can export them later refs: Refs = {} // Keep track of already used type aliases #usedAliasNames: Record<string, number> = {} /** * Creates a type node from a given schema. * Delegates to getBaseTypeFromSchema internally and * optionally adds a union with null. */ parse(props: SchemaProps): Schema[] { const options = this.#getOptions(props) const defaultSchemas = this.#parseSchemaObject(props) const schemas = options.transformers?.schema?.(props, defaultSchemas) || defaultSchemas || [] return uniqueWith(schemas, isDeepEqual) } deepSearch<T extends keyof SchemaKeywordMapper>(tree: Schema[] | undefined, keyword: T): Array<SchemaKeywordMapper[T]> { return SchemaGenerator.deepSearch<T>(tree, keyword) } find<T extends keyof SchemaKeywordMapper>(tree: Schema[] | undefined, keyword: T): SchemaKeywordMapper[T] | undefined { return SchemaGenerator.find<T>(tree, keyword) } static deepSearch<T extends keyof SchemaKeywordMapper>(tree: Schema[] | undefined, keyword: T): Array<SchemaKeywordMapper[T]> { const foundItems: SchemaKeywordMapper[T][] = [] tree?.forEach((schema) => { if (schema.keyword === keyword) { foundItems.push(schema as SchemaKeywordMapper[T]) } if (isKeyword(schema, schemaKeywords.object)) { Object.values(schema.args?.properties || {}).forEach((entrySchema) => { foundItems.push(...SchemaGenerator.deepSearch<T>(entrySchema, keyword)) }) Object.values(schema.args?.additionalProperties || {}).forEach((entrySchema) => { foundItems.push(...SchemaGenerator.deepSearch<T>([entrySchema], keyword)) }) } if (isKeyword(schema, schemaKeywords.array)) { schema.args.items.forEach((entrySchema) => { foundItems.push(...SchemaGenerator.deepSearch<T>([entrySchema], keyword)) }) } if (isKeyword(schema, schemaKeywords.and)) { schema.args.forEach((entrySchema) => { foundItems.push(...SchemaGenerator.deepSearch<T>([entrySchema], keyword)) }) } if (isKeyword(schema, schemaKeywords.tuple)) { schema.args.items.forEach((entrySchema) => { foundItems.push(...SchemaGenerator.deepSearch<T>([entrySchema], keyword)) }) } if (isKeyword(schema, schemaKeywords.union)) { schema.args.forEach((entrySchema) => { foundItems.push(...SchemaGenerator.deepSearch<T>([entrySchema], keyword)) }) } }) return foundItems } static findInObject<T extends keyof SchemaKeywordMapper>(tree: Schema[] | undefined, keyword: T): SchemaKeywordMapper[T] | undefined { let foundItem: SchemaKeywordMapper[T] | undefined = undefined tree?.forEach((schema) => { if (!foundItem && schema.keyword === keyword) { foundItem = schema as SchemaKeywordMapper[T] } if (isKeyword(schema, schemaKeywords.object)) { Object.values(schema.args?.properties || {}).forEach((entrySchema) => { if (!foundItem) { foundItem = SchemaGenerator.find<T>(entrySchema, keyword) } }) Object.values(schema.args?.additionalProperties || {}).forEach((entrySchema) => { if (!foundItem) { foundItem = SchemaGenerator.find<T>([entrySchema], keyword) } }) } }) return foundItem } static find<T extends keyof SchemaKeywordMapper>(tree: Schema[] | undefined, keyword: T): SchemaKeywordMapper[T] | undefined { let foundItem: SchemaKeywordMapper[T] | undefined = undefined tree?.forEach((schema) => { if (!foundItem && schema.keyword === keyword) { foundItem = schema as SchemaKeywordMapper[T] } if (isKeyword(schema, schemaKeywords.array)) { schema.args.items.forEach((entrySchema) => { if (!foundItem) { foundItem = SchemaGenerator.find<T>([entrySchema], keyword) } }) } if (isKeyword(schema, schemaKeywords.and)) { schema.args.forEach((entrySchema) => { if (!foundItem) { foundItem = SchemaGenerator.find<T>([entrySchema], keyword) } }) } if (isKeyword(schema, schemaKeywords.tuple)) { schema.args.items.forEach((entrySchema) => { if (!foundItem) { foundItem = SchemaGenerator.find<T>([entrySchema], keyword) } }) } if (isKeyword(schema, schemaKeywords.union)) { schema.args.forEach((entrySchema) => { if (!foundItem) { foundItem = SchemaGenerator.find<T>([entrySchema], keyword) } }) } }) return foundItem } #getUsedEnumNames(props: SchemaProps) { const options = this.#getOptions(props) return options.usedEnumNames || {} } #getOptions({ name }: SchemaProps): Partial<TOptions> { const { override = [] } = this.context return { ...this.options, ...(override.find(({ pattern, type }) => { if (name && type === 'schemaName') { return !!name.match(pattern) } return false })?.options || {}), } } #getUnknownReturn(props: SchemaProps) { const options = this.#getOptions(props) if (options.unknownType === 'any') { return schemaKeywords.any } if (options.unknownType === 'void') { return schemaKeywords.void } return schemaKeywords.unknown } /** * Recursively creates a type literal with the given props. */ #parseProperties({ schema, name }: SchemaProps): Schema[] { const properties = schema?.properties || {} const additionalProperties = schema?.additionalProperties const required = schema?.required const propertiesSchemas = Object.keys(properties) .map((propertyName) => { const validationFunctions: Schema[] = [] const propertySchema = properties[propertyName] as SchemaObject const isRequired = Array.isArray(required) ? required?.includes(propertyName) : !!required const nullable = propertySchema.nullable ?? propertySchema['x-nullable'] ?? false validationFunctions.push(...this.parse({ schema: propertySchema, name: propertyName, parentName: name })) validationFunctions.push({ keyword: schemaKeywords.name, args: propertyName, }) if (!isRequired && nullable) { validationFunctions.push({ keyword: schemaKeywords.nullish }) } else if (!isRequired) { validationFunctions.push({ keyword: schemaKeywords.optional }) } return { [propertyName]: validationFunctions, } }) .reduce((acc, curr) => ({ ...acc, ...curr }), {}) let additionalPropertiesSchemas: Schema[] = [] if (additionalProperties) { additionalPropertiesSchemas = additionalProperties === true || !Object.keys(additionalProperties).length ? [{ keyword: this.#getUnknownReturn({ schema, name }) }] : this.parse({ schema: additionalProperties as SchemaObject, parentName: name }) } return [ { keyword: schemaKeywords.object, args: { properties: propertiesSchemas, additionalProperties: additionalPropertiesSchemas, }, }, ] } /** * Create a type alias for the schema referenced by the given ReferenceObject */ #getRefAlias(obj: OpenAPIV3.ReferenceObject): Schema[] { const { $ref } = obj let ref = this.refs[$ref] const originalName = getUniqueName($ref.replace(/.+\//, ''), this.#usedAliasNames) const propertyName = this.context.pluginManager.resolveName({ name: originalName, pluginKey: this.context.plugin.key, type: 'function', }) if (ref) { return [ { keyword: schemaKeywords.ref, args: { name: ref.propertyName, path: ref.path, isImportable: !!this.context.oas.get($ref) }, }, ] } const fileName = this.context.pluginManager.resolveName({ name: originalName, pluginKey: this.context.plugin.key, type: 'file', }) const file = this.context.pluginManager.getFile({ name: fileName, pluginKey: this.context.plugin.key, extname: '.ts', }) ref = this.refs[$ref] = { propertyName, originalName, path: file.path, } return [ { keyword: schemaKeywords.ref, args: { name: ref.propertyName, path: ref?.path, isImportable: !!this.context.oas.get($ref) }, }, ] } #getParsedSchemaObject(schema?: SchemaObject) { const parsedSchema = getSchemaFactory(this.context.oas)(schema) return parsedSchema } /** * This is the very core of the OpenAPI to TS conversion - it takes a * schema and returns the appropriate type. */ #parseSchemaObject({ schema: _schema, name, parentName }: SchemaProps): Schema[] { const options = this.#getOptions({ schema: _schema, name }) const unknownReturn = this.#getUnknownReturn({ schema: _schema, name }) const { schema, version } = this.#getParsedSchemaObject(_schema) if (!schema) { return [{ keyword: unknownReturn }] } const baseItems: Schema[] = [ { keyword: schemaKeywords.schema, args: { type: schema.type as any, format: schema.format, }, }, ] const min = schema.minimum ?? schema.minLength ?? schema.minItems ?? undefined const max = schema.maximum ?? schema.maxLength ?? schema.maxItems ?? undefined const nullable = isNullable(schema) const defaultNullAndNullable = schema.default === null && nullable if (schema.default !== undefined && !defaultNullAndNullable && !Array.isArray(schema.default)) { if (typeof schema.default === 'string') { baseItems.push({ keyword: schemaKeywords.default, args: transformers.stringify(schema.default), }) } else if (typeof schema.default === 'boolean') { baseItems.push({ keyword: schemaKeywords.default, args: schema.default ?? false, }) } else { baseItems.push({ keyword: schemaKeywords.default, args: schema.default, }) } } if (schema.deprecated) { baseItems.push({ keyword: schemaKeywords.deprecated, }) } if (schema.description) { baseItems.push({ keyword: schemaKeywords.describe, args: schema.description, }) } if (max !== undefined) { baseItems.unshift({ keyword: schemaKeywords.max, args: max }) } if (min !== undefined) { baseItems.unshift({ keyword: schemaKeywords.min, args: min }) } if (nullable) { baseItems.push({ keyword: schemaKeywords.nullable }) } if (schema.type && Array.isArray(schema.type)) { const [_schema, nullable] = schema.type if (nullable === 'null') { baseItems.push({ keyword: schemaKeywords.nullable }) } } if (schema.readOnly) { baseItems.push({ keyword: schemaKeywords.readOnly }) } if (schema.writeOnly) { baseItems.push({ keyword: schemaKeywords.writeOnly }) } if (isReference(schema)) { return [ ...this.#getRefAlias(schema), nullable && { keyword: schemaKeywords.nullable }, schema.readOnly && { keyword: schemaKeywords.readOnly }, schema.writeOnly && { keyword: schemaKeywords.writeOnly }, { keyword: schemaKeywords.schema, args: { type: schema.type as any, format: schema.format, }, }, ].filter(Boolean) } if (schema.oneOf) { // union const schemaWithoutOneOf = { ...schema, oneOf: undefined } const union: Schema = { keyword: schemaKeywords.union, args: schema.oneOf .map((item) => { return item && this.parse({ schema: item as SchemaObject, name, parentName })[0] }) .filter(Boolean) .filter((item) => { return item && item.keyword !== unknownReturn }), } if (schemaWithoutOneOf.properties) { const propertySchemas = this.parse({ schema: schemaWithoutOneOf, name, parentName }) union.args = [ ...union.args.map((arg) => { return { keyword: schemaKeywords.and, args: [arg, ...propertySchemas], } }), ] } return [union, ...baseItems] } if (schema.anyOf) { // union const schemaWithoutAnyOf = { ...schema, anyOf: undefined } const union: Schema = { keyword: schemaKeywords.union, args: schema.anyOf .map((item) => { return item && this.parse({ schema: item as SchemaObject, name, parentName })[0] }) .filter(Boolean) .filter((item) => { return item && item.keyword !== unknownReturn }) .map((item) => { if (isKeyword(item, schemaKeywords.object)) { return { ...item, args: { ...item.args, strict: true, }, } } return item }), } if (schemaWithoutAnyOf.properties) { return [...this.parse({ schema: schemaWithoutAnyOf, name, parentName }), union, ...baseItems] } return [union, ...baseItems] } if (schema.allOf) { // intersection/add const schemaWithoutAllOf = { ...schema, allOf: undefined } const and: Schema = { keyword: schemaKeywords.and, args: schema.allOf .map((item) => { return item && this.parse({ schema: item as SchemaObject, name, parentName })[0] }) .filter(Boolean) .filter((item) => { return item && item.keyword !== unknownReturn }), } if (schemaWithoutAllOf.required) { // TODO use of Required ts helper instead const schemas = schema.allOf .map((item) => { if (isReference(item)) { return this.context.oas.get(item.$ref) as SchemaObject } }) .filter(Boolean) const items = schemaWithoutAllOf.required .filter((key) => { // filter out keys that are already part of the properties(reduce duplicated keys(https://github.com/kubb-labs/kubb/issues/1492) if (schemaWithoutAllOf.properties) { return !Object.keys(schemaWithoutAllOf.properties).includes(key) } // schema should include required fields when necessary https://github.com/kubb-labs/kubb/issues/1522 return true }) .map((key) => { const schema = schemas.find((item) => item.properties && Object.keys(item.properties).find((propertyKey) => propertyKey === key)) if (schema?.properties?.[key]) { return { ...schema, properties: { [key]: schema.properties[key], }, required: [key], } } }) .filter(Boolean) and.args = [...(and.args || []), ...items.flatMap((item) => this.parse({ schema: item as SchemaObject, name, parentName }))] } if (schemaWithoutAllOf.properties) { and.args = [...(and.args || []), ...this.parse({ schema: schemaWithoutAllOf, name, parentName })] } return [and, ...baseItems] } if (schema.enum) { if (options.enumSuffix === '') { throw new Error('EnumSuffix set to an empty string does not work') } const enumName = getUniqueName(pascalCase([parentName, name, options.enumSuffix].join(' ')), this.#getUsedEnumNames({ schema, name })) const typeName = this.context.pluginManager.resolveName({ name: enumName, pluginKey: this.context.plugin.key, type: 'type', }) const nullableEnum = schema.enum.includes(null) if (nullableEnum) { baseItems.push({ keyword: schemaKeywords.nullable }) } const filteredValues = schema.enum.filter((value) => value !== null) // x-enumNames has priority const extensionEnums = ['x-enumNames', 'x-enum-varnames'] .filter((extensionKey) => extensionKey in schema) .map((extensionKey) => { return [ { keyword: schemaKeywords.enum, args: { name, typeName, asConst: false, items: [...new Set(schema[extensionKey as keyof typeof schema] as string[])].map((name: string | number, index) => ({ name: transformers.stringify(name), value: schema.enum?.[index] as string | number, format: isNumber(schema.enum?.[index]) ? 'number' : 'string', })), }, }, ...baseItems.filter( (item) => item.keyword !== schemaKeywords.min && item.keyword !== schemaKeywords.max && item.keyword !== schemaKeywords.matches, ), ] }) if (schema.type === 'number' || schema.type === 'integer') { // we cannot use z.enum when enum type is number/integer const enumNames = extensionEnums[0]?.find((item) => isKeyword(item, schemaKeywords.enum)) as unknown as SchemaKeywordMapper['enum'] return [ { keyword: schemaKeywords.enum, args: { name: enumName, typeName, asConst: true, items: enumNames?.args?.items ? [...new Set(enumNames.args.items)].map(({ name, value }) => ({ name, value, format: 'number', })) : [...new Set(filteredValues)].map((value: string) => { return { name: value, value, format: 'number', } }), }, }, ...baseItems.filter((item) => item.keyword !== schemaKeywords.min && item.keyword !== schemaKeywords.max && item.keyword !== schemaKeywords.matches), ] } if (schema.type === 'boolean') { // we cannot use z.enum when enum type is boolean const enumNames = extensionEnums[0]?.find((item) => isKeyword(item, schemaKeywords.enum)) as unknown as SchemaKeywordMapper['enum'] return [ { keyword: schemaKeywords.enum, args: { name: enumName, typeName, asConst: true, items: enumNames?.args?.items ? [...new Set(enumNames.args.items)].map(({ name, value }) => ({ name, value, format: 'boolean', })) : [...new Set(filteredValues)].map((value: string) => { return { name: value, value, format: 'boolean', } }), }, }, ...baseItems.filter((item) => item.keyword !== schemaKeywords.matches), ] } if (extensionEnums.length > 0 && extensionEnums[0]) { return extensionEnums[0] } return [ { keyword: schemaKeywords.enum, args: { name: enumName, typeName, asConst: false, items: [...new Set(filteredValues)].map((value: string) => ({ name: transformers.stringify(value), value, format: isNumber(value) ? 'number' : 'string', })), }, }, ...baseItems.filter((item) => item.keyword !== schemaKeywords.min && item.keyword !== schemaKeywords.max && item.keyword !== schemaKeywords.matches), ] } if ('prefixItems' in schema) { const prefixItems = schema.prefixItems as SchemaObject[] const min = schema.minimum ?? schema.minLength ?? schema.minItems ?? undefined const max = schema.maximum ?? schema.maxLength ?? schema.maxItems ?? undefined return [ { keyword: schemaKeywords.tuple, args: { min, max, items: prefixItems .map((item) => { return this.parse({ schema: item, name, parentName })[0] }) .filter(Boolean), }, }, ...baseItems.filter((item) => item.keyword !== schemaKeywords.min && item.keyword !== schemaKeywords.max), ] } if (version === '3.1' && 'const' in schema) { // const keyword takes precendence over the actual type. if (schema['const']) { return [ { keyword: schemaKeywords.const, args: { name: schema['const'], format: typeof schema['const'] === 'number' ? 'number' : 'string', value: schema['const'], }, }, ...baseItems, ] } return [{ keyword: schemaKeywords.null }] } /** * > Structural validation alone may be insufficient to allow an application to correctly utilize certain values. The "format" * > annotation keyword is defined to allow schema authors to convey semantic information for a fixed subset of values which are * > accurately described by authoritative resources, be they RFCs or other external specifications. * * In other words: format is more specific than type alone, hence it should override the type value, if possible. * * see also https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.7 */ if (schema.format) { switch (schema.format) { case 'binary': baseItems.push({ keyword: schemaKeywords.blob }) return baseItems case 'date-time': if (options.dateType) { if (options.dateType === 'date') { baseItems.unshift({ keyword: schemaKeywords.date, args: { type: 'date' } }) return baseItems } if (options.dateType === 'stringOffset') { baseItems.unshift({ keyword: schemaKeywords.datetime, args: { offset: true } }) return baseItems } if (options.dateType === 'stringLocal') { baseItems.unshift({ keyword: schemaKeywords.datetime, args: { local: true } }) return baseItems } baseItems.unshift({ keyword: schemaKeywords.datetime, args: { offset: false } }) return baseItems } break case 'date': if (options.dateType) { if (options.dateType === 'date') { baseItems.unshift({ keyword: schemaKeywords.date, args: { type: 'date' } }) return baseItems } baseItems.unshift({ keyword: schemaKeywords.date, args: { type: 'string' } }) return baseItems } break case 'time': if (options.dateType) { if (options.dateType === 'date') { baseItems.unshift({ keyword: schemaKeywords.time, args: { type: 'date' } }) return baseItems } baseItems.unshift({ keyword: schemaKeywords.time, args: { type: 'string' } }) return baseItems } break case 'uuid': baseItems.unshift({ keyword: schemaKeywords.uuid }) return baseItems case 'email': case 'idn-email': baseItems.unshift({ keyword: schemaKeywords.email }) return baseItems case 'uri': case 'ipv4': case 'ipv6': case 'uri-reference': case 'hostname': case 'idn-hostname': baseItems.unshift({ keyword: schemaKeywords.url }) return baseItems // case 'duration': // case 'json-pointer': // case 'relative-json-pointer': default: // formats not yet implemented: ignore. break } } if (schema.pattern) { baseItems.unshift({ keyword: schemaKeywords.matches, args: schema.pattern, }) return baseItems } // type based logic if ('items' in schema || schema.type === ('array' as 'string')) { const min = schema.minimum ?? schema.minLength ?? schema.minItems ?? undefined const max = schema.maximum ?? schema.maxLength ?? schema.maxItems ?? undefined const items = this.parse({ schema: 'items' in schema ? (schema.items as SchemaObject) : [], name, parentName }) const unique = !!schema.uniqueItems return [ { keyword: schemaKeywords.array, args: { items, min, max, unique, }, }, ...baseItems.filter((item) => item.keyword !== schemaKeywords.min && item.keyword !== schemaKeywords.max), ] } if (schema.properties || schema.additionalProperties) { return [...this.#parseProperties({ schema, name }), ...baseItems] } if (schema.type) { if (Array.isArray(schema.type)) { // OPENAPI v3.1.0: https://www.openapis.org/blog/2021/02/16/migrating-from-openapi-3-0-to-3-1-0 const [type] = schema.type as Array<OpenAPIV3.NonArraySchemaObjectType> return [ ...this.parse({ schema: { ...schema, type, }, name, parentName, }), ...baseItems, ].filter(Boolean) } if (!['boolean', 'object', 'number', 'string', 'integer', 'null'].includes(schema.type)) { this.context.pluginManager.logger.emit('warning', `Schema type '${schema.type}' is not valid for schema ${parentName}.${name}`) } // 'string' | 'number' | 'integer' | 'boolean' return [{ keyword: schema.type }, ...baseItems] } return [{ keyword: unknownReturn }] } async build(...generators: Array<Generator<TPluginOptions>>): Promise<Array<KubbFile.File<TFileMeta>>> { const { oas, contentType, include } = this.context oas.resolveDiscriminators() const schemas = getSchemas({ oas, contentType, includes: include }) const promises = Object.entries(schemas).reduce((acc, [name, value]) => { if (!value) { return acc } const options = this.#getOptions({ name }) const promiseOperation = this.schema.call(this, name, value, { ...this.options, ...options, }) if (promiseOperation) { acc.push(promiseOperation) } generators?.forEach((generator) => { const tree = this.parse({ schema: value, name: name }) const promise = generator.schema?.({ instance: this, schema: { name, value, tree, }, options: { ...this.options, ...options, }, } as any) as Promise<Array<KubbFile.File<TFileMeta>>> if (promise) { acc.push(promise) } }) return acc }, [] as SchemaMethodResult<TFileMeta>[]) const files = await Promise.all(promises) // using .flat because schemaGenerator[method] can return an array of files or just one file return files.flat().filter(Boolean) } /** * Schema */ async schema(_name: string, _object: SchemaObject, _options: TOptions): SchemaMethodResult<TFileMeta> { return [] } }