UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

1,289 lines (1,191 loc) 34.2 kB
import type { Models } from '../../models'; import type { GenericType, Links, SpecFragment, BuiltLinks, } from '../../typings'; import filter from 'lodash/filter'; import get from 'lodash/get'; import has from 'lodash/has'; import isObject from 'lodash/isObject'; import merge from 'lodash/merge'; import omit from 'lodash/omit'; import * as c from '../../constants'; import components from './components'; const MIME_APPLICATION_JSON = 'application/json'; export function build(origin: SpecFragment, models: Models) { let spec: SpecFragment = merge( { paths: { [`/stream/{model}/{source}`]: { post: stream(models), }, [`/stream/{model}/{source}/sse`]: { get: stream(models, true), }, }, }, origin, ); for (const [modelName, model] of new Map<string, GenericType>( [...models.MODELS.entries()].sort(), )) { if (models.isInternalModel(modelName) === false) { const links = findLinks(model, models); spec = buildFromModel(spec, model, links); } } return spec; } function walkKeysDeep(obj: any, cb: (k: any, p: any) => any, p: any[] = []) { for (const k in obj) { cb(k, p); if (isObject(obj[k])) { walkKeysDeep(obj[k], cb, [...p, k]); } } } function showHandler(event: any) { if (event['x-show-handler'] !== true) { return ''; } let description = ''; if (has(event, 'handler')) { description += ` ### Handler \`\`\` function handler(state, event) { ${get(event, 'handler')} } \`\`\``; } return description; } export function findLinks(model: GenericType, models: Models): Links { const links: Links = {}; walkKeysDeep(model.getSchema().model.properties, (k: any, p: any) => { if (k.endsWith('_id') && k !== model.getCorrelationField()) { const linkedModel = models.getModelByCorrelationField(k); /* istanbul ignore next */ if (linkedModel !== null) { links[k] = { path: [ ...p.filter( (fragment: any) => !['items', 'properties'].includes(fragment), ), k, ].join('/'), model: linkedModel, }; } } }); return links; } export function eventNametoCamelCase(str: string) { return str .toLowerCase() .replace(/([-_][a-zA-Z])/g, (group) => group.toUpperCase().replace('-', '').replace('_', ''), ); } export function buildFromModel( spec: SpecFragment, model: GenericType, links: Links, ) { const collection = model.getCollectionName(); const schema = model.getSchema(); const modelConfig = model.getModelConfig(); const tagName = getEntityName(model); const entityName = getEntityName(model, true); const events = Object.keys(schema.events); const additionnalPaths = buildAdditionalPaths(model); const component = schema.model; const processings = modelConfig.processings ?? []; const processingsStr = processings .map((processing, i) => { const title = 'Activity ' + tagName[0] + '.' + (i + 1) + ' - `' + processing.field + '` - ' + processing.name; let str = '### ' + title + '\n\n'; str += processing.purpose + '\n'; if (Array.isArray(processing.tokens)) { str += '#### tokens\n'; str += processing.tokens.map((p) => `- \`${p}\``).join('\n'); str += '\n\n'; } str += '#### Persons\n'; str += processing.persons.map((p) => `- ${p}`).join('\n'); str += '\n\n'; str += '#### Recipients\n'; str += processing.recipients.map((p) => `- ${p}`).join('\n'); str += '\n\n'; return str; }) .join('\n'); return { ...spec, components: { ...spec.components, schemas: { ...spec.components.schemas, [entityName]: { ...component, required: component.required ?? undefined, }, }, }, tags: [ ...(spec.tags || []), { name: tagName, description: `${ modelConfig.description ?? 'Routes available for the model <code>' + entityName + '</code>' } Events available to this model: ${ events.length > 0 ? events.map((event) => '<code>' + event + '</code>').join(', ') : 'none' }. <details> <summary>JSON Schema</summary> \`\`\`json ${JSON.stringify(schema.model, null, 2)} \`\`\` </summary> </details> <details> <summary>Data Processings (${processings.length})</summary> ${processingsStr} </summary> </details> `, }, ], paths: merge( {}, spec.paths, { [`/${collection}`]: { get: getModels(model, links), }, [`/${collection}/encrypt`]: { post: encrypt(model, links), }, [`/${collection}/decrypt`]: { post: decrypt(model, links), }, [`/${collection}/events`]: { post: getModelEvents(model, links, { skipCorrelationField: true }), }, [`/${collection}/{${model.getCorrelationField()}}`]: { get: getModel(model, links), }, [`/${collection}/{${model.getCorrelationField()}}/events`]: { get: getModelEvents(model, links), }, [`/${collection}/{${model.getCorrelationField()}}/snapshot`]: { post: createModelSnapshot(model, links), }, [`/${collection}/{${model.getCorrelationField()}}/{version}`]: { get: getModelAtVersion(model, links), }, }, events.includes(c.EVENT_TYPE_CREATED) && { [`/${collection}`]: { post: createModel(model, links), }, }, events.includes(c.EVENT_TYPE_UPDATED) && { [`/${collection}/{${model.getCorrelationField()}}`]: { post: updateModel(model, links), }, }, events.includes(c.EVENT_TYPE_PATCHED) && { [`/${collection}/{${model.getCorrelationField()}}`]: { patch: patchModel(model, links), }, }, events.includes(c.EVENT_TYPE_RESTORED) && { [`/${collection}/{${model.getCorrelationField()}}/{version}/restore`]: { post: restoreVersion(model, links), }, }, additionnalPaths, ), }; } export function getEntityName(model: GenericType | string, singular = false) { let collection = typeof model === 'string' ? model : model.getCollectionName(); if (singular === true) { collection = collection.replace(/s$/, ''); } return collection.charAt(0).toUpperCase() + collection.slice(1); } function buildLinks(links: Links) { const _links: BuiltLinks = {}; for (const key in links) { _links[getEntityName(links[key].model, true)] = { operationId: eventNametoCamelCase( `GET_${getEntityName(links[key].model, true)}`, ), parameters: { /** * @fixme Apply the camelCase only for GraphQL because in the current * implementation, the link on REST is invalid */ [key]: `$response.body#/${eventNametoCamelCase(links[key].path)}`, // For GraphQL }, }; } return _links; } export function defaultSchema(model: GenericType) { return { tags: [getEntityName(model)], security: [ { apiKey: [], }, ], responses: { default: { $ref: '#/components/responses/default', }, }, }; } export function buildAdditionalPaths(model: GenericType) { const additionnalPaths: { [key: string]: {} } = {}; const collection = model.getCollectionName(); const schema = model.getSchema(); const originalSchema = model.getOriginalSchema(); const events = schema.events; const entityName = getEntityName(model, true); for (const eventType in events) { if ( ![ c.EVENT_TYPE_CREATED, c.EVENT_TYPE_UPDATED, c.EVENT_TYPE_RESTORED, c.EVENT_TYPE_ROLLBACKED, c.EVENT_TYPE_PATCHED, c.EVENT_TYPE_ARCHIVED, c.EVENT_TYPE_DELETED, ].includes(eventType) ) { const eventTypeLowered = eventType.toLocaleLowerCase(); for (const eventVersion in events[eventType]) { const event = events[eventType][eventVersion]; const required = filter( event.required, (r) => !['v', 'type'].includes(r), ); const routeSpec = merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`${entityName}_${eventType}`), summary: event.title || `${eventTypeLowered}:v${eventVersion}`, description: event.description || `Business event <code>${eventType}</code> at version <code>${eventVersion}</code>`, parameters: [ { in: 'path', name: model.getCorrelationField(), description: 'Correlation id', schema: c.COMPONENT_CORRELATION_ID, required: true, }, ], requestBody: { content: { [MIME_APPLICATION_JSON]: { schema: { ...omit( event, 'handler', 'handlers', 'x-responses', 'x-show-handler', 'is_created', 'is_fhe', 'upsert', ), properties: { ...omit( event.properties, 'v', 'type', 'version', 'json_patch', 'created_at', 'updated_at', ), }, required: required.length ? required : undefined, }, }, }, }, responses: { 200: { description: 'Entity successfully updated', content: { [MIME_APPLICATION_JSON]: { schema: { $ref: `#/components/schemas/${entityName}`, }, }, }, }, 400: { ...components.responses[400], description: 'Invalid Model / Event schema validation error', }, 409: components.responses[409], 422: components.responses[422], }, }); routeSpec.description += showHandler(event); const additionalResponses: any = {}; (event['x-responses'] || []).forEach((response: any) => { additionalResponses[response.status] = { description: response.description, content: { 'application/json': { example: { status: response.status, message: response.description, details: response.details, }, schema: { $ref: '#/components/schemas/error', }, }, }, }; }); routeSpec.responses = { ...routeSpec.responses, ...additionalResponses, }; const originalEvent = get( originalSchema, `events.${eventType}.${eventVersion}`, {}, ); routeSpec.requestBody.content[MIME_APPLICATION_JSON].schema.properties = { ...routeSpec.requestBody.content[MIME_APPLICATION_JSON].schema .properties, ...omit( originalEvent.properties, 'v', 'type', 'version', 'json_patch', 'created_at', 'updated_at', ), }; additionnalPaths[ `/${collection}/{${model.getCorrelationField()}}/${eventTypeLowered}/${eventVersion}` ] = { post: routeSpec, }; } } } return additionnalPaths; } export function stream(models: Models, isSSE: boolean = false) { const entityName = 'all'; return merge( {}, { operationId: eventNametoCamelCase( `stream_${entityName}${isSSE === true ? '_sse' : ''}`, ), summary: `Stream ${entityName} changes${isSSE === true ? ' SSE' : ''}`, description: `Stream <code>${entityName}</code> changes in the database such as creation or update${isSSE === true ? ' with Server Sent Events' : ''}.`, parameters: [ { in: 'path', name: 'model', description: 'Model name', schema: { type: 'string', example: 'users', enum: [ 'all', ...Array.from(models.MODELS.keys()).filter( (m) => models.isInternalModel(m) === false, ), ], }, required: true, }, { in: 'path', name: 'source', description: 'Data source to stream', schema: { type: 'string', enum: ['entities', 'events'], example: 'entities', }, required: true, }, { in: 'header', name: 'output', description: 'Expected output', schema: { type: 'string', enum: ['entity', 'raw'], example: 'entity', }, required: false, }, ], ...(isSSE === false && { requestBody: { content: { [MIME_APPLICATION_JSON]: { schema: { type: 'array', items: { type: 'object', }, default: [], example: [ { $match: { 'fullDocument.firstname': 'John', }, }, ], }, }, }, }, }), responses: { 200: { description: 'Stream of JSON objects', content: { [isSSE === true ? 'text/event-stream' : MIME_APPLICATION_JSON]: { schema: { type: 'array', items: { type: 'object', }, example: [ { firstname: 'John', }, ], }, }, }, }, 400: components.responses[400], 404: components.responses[404], }, }, ); } export function createModel(model: GenericType, links: Links) { const schema = model.getSchema(); const event = schema.events[c.EVENT_TYPE_CREATED]['0_0_0']; const required = filter(event.required, (r) => !['v', 'type'].includes(r)); const entityName = getEntityName(model, true); const routeSpec = merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`create_${entityName}`), summary: event.title || `Create a new ${entityName}`, description: event.description || `Create a new <code>${entityName}</code> in the database.`, requestBody: { content: { [MIME_APPLICATION_JSON]: { schema: { ...omit(event, 'handler'), properties: { ...omit( event.properties, 'v', 'type', 'version', 'json_patch', 'created_at', 'updated_at', model.getIsReadonlyProperty(model.getModelConfig()), model.getIsArchivedProperty(model.getModelConfig()), model.getIsDeletedProperty(model.getModelConfig()), ), }, required: required.length ? required : undefined, }, }, }, }, responses: { 200: { description: 'Entity successfully created', content: { [MIME_APPLICATION_JSON]: { schema: { $ref: `#/components/schemas/${entityName}`, }, }, }, links: buildLinks(links), }, 400: { ...components.responses[400], description: 'Invalid Model / Event schema validation error', }, 409: components.responses[409], }, }); routeSpec.description += showHandler(event); const originalSchema = model.getOriginalSchema(); if (originalSchema) { const originalEvent = get( originalSchema, `events.${c.EVENT_TYPE_CREATED}.0_0_0`, {}, ); routeSpec.requestBody.content[MIME_APPLICATION_JSON].schema.properties = { ...routeSpec.requestBody.content[MIME_APPLICATION_JSON].schema.properties, ...omit( originalEvent.properties, 'v', 'type', 'version', 'json_patch', 'created_at', 'updated_at', ), }; } return routeSpec; } export function updateModel(model: GenericType, links: Links) { const schema = model.getSchema(); const event = schema.events[c.EVENT_TYPE_UPDATED]['0_0_0']; const required = filter(event.required, (r) => !['v', 'type'].includes(r)); const entityName = getEntityName(model, true); const routeSpec = merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`update_${entityName}`), summary: event.title || `Update a ${entityName}`, description: event.description || `Update an existing <code>${entityName}</code> already present in the database.`, parameters: [ { in: 'path', name: model.getCorrelationField(), description: 'Correlation id', schema: c.COMPONENT_CORRELATION_ID, required: true, }, ], requestBody: { content: { [MIME_APPLICATION_JSON]: { schema: { ...omit(event, 'handler'), properties: { ...omit( event.properties, 'v', 'type', 'version', 'json_patch', 'created_at', 'updated_at', model.getIsReadonlyProperty(model.getModelConfig()), model.getIsArchivedProperty(model.getModelConfig()), model.getIsDeletedProperty(model.getModelConfig()), ), }, required: required.length ? required : undefined, }, }, }, }, responses: { 200: { description: 'Entity successfully updated', content: { [MIME_APPLICATION_JSON]: { schema: { $ref: `#/components/schemas/${entityName}`, }, }, }, links: buildLinks(links), }, 400: { ...components.responses[400], description: 'Invalid Model / Event schema validation error', }, 409: components.responses[409], 422: components.responses[422], }, }); routeSpec.description += showHandler(event); const originalSchema = model.getOriginalSchema(); if (originalSchema) { const originalEvent = get( originalSchema, `events.${c.EVENT_TYPE_UPDATED}.0_0_0`, {}, ); routeSpec.requestBody.content[MIME_APPLICATION_JSON].schema.properties = { ...routeSpec.requestBody.content[MIME_APPLICATION_JSON].schema.properties, ...omit( originalEvent.properties, 'v', 'type', 'version', 'json_patch', 'created_at', 'updated_at', ), }; } return routeSpec; } export function patchModel(model: GenericType, links: Links) { const schema = model.getSchema(); const event = schema.events[c.EVENT_TYPE_PATCHED]['0_0_0']; const entityName = getEntityName(model, true); const routeSpec = merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`patch_${entityName}`), summary: event.title || `Patch a ${entityName}`, description: event.description || `Patch an existing <code>${entityName}</code> already present in the database.`, parameters: [ { in: 'path', name: model.getCorrelationField(), description: 'Correlation id', schema: c.COMPONENT_CORRELATION_ID, required: true, }, ], requestBody: { content: { [MIME_APPLICATION_JSON]: { schema: { type: 'object', required: ['json_patch'], additionalProperties: false, properties: { json_patch: event.properties.json_patch, }, }, }, }, }, responses: { 200: { description: 'Entity successfully patched', content: { [MIME_APPLICATION_JSON]: { schema: { $ref: `#/components/schemas/${entityName}`, }, }, }, links: buildLinks(links), }, 400: { ...components.responses[400], description: 'Invalid Model / Event schema validation error', }, 409: components.responses[409], 422: components.responses[422], }, }); routeSpec.description += showHandler(event); return routeSpec; } export function getModels(model: GenericType, links: Links) { const modelConfig = model.getModelConfig(); const originalSchema = model.getOriginalSchema(); const schema = model.getSchema(); const entityName = getEntityName(model); const indexedFields = modelConfig.indexes?.reduce( (current, index) => [...current, ...Object.keys(index.fields)], [modelConfig.correlation_field], ); const parameters: any[] = Object.keys(schema.model.properties) .filter((key) => { return ![ model.getIsArchivedProperty(modelConfig), model.getIsDeletedProperty(modelConfig), ].includes(key); }) .map((key) => { const isIndexed = indexedFields?.includes(key); const paramSchema = { // Remove default value in the GET find query parameters: ...omit(schema.model.properties[key], 'default'), description: `${isIndexed ? '<code>index</code> ' : ''}${ schema.model.properties[key].description ?? '' }`, }; const _schema: any = { anyOf: [ paramSchema, { type: 'array', items: paramSchema, }, { type: 'object', }, ], }; return { in: 'query', name: key, description: `${isIndexed ? '<code>index</code> ' : ''}${ schema.model.properties[key].description ?? '' }`, schema: _schema, required: false, }; }); // Raw queries support: parameters.push( { in: 'query', name: '_q', description: 'MongoDb query in JSON stringified', schema: { type: 'string', }, }, { in: 'query', name: '_must_hash', description: 'Do we need to hash query values before find results if needed?', schema: { type: 'boolean', }, }, { in: 'header', name: 'page', ...components.headers['pagination-page'], }, { in: 'header', name: 'page-size', ...components.headers['pagination-size'], }, { in: 'header', name: 'decrypt', description: 'should we decipher the value', schema: { type: 'string', enum: ['true', 'false'], }, }, ); return merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`GET_${entityName}`), summary: `Find ${entityName}`, description: `Find <code>${entityName}</code> present in the database.`, parameters, responses: { 200: { description: 'List of entities', content: { [MIME_APPLICATION_JSON]: { schema: { type: 'array', items: { $ref: `#/components/schemas/${getEntityName(model, true)}`, }, }, }, }, headers: { 'correlation-field': { description: 'Correlation field of the model', schema: { type: 'string', }, }, page: { ...components.headers['pagination-page'], }, 'page-size': { ...components.headers['pagination-size'], }, 'page-count': { ...components.headers['pagination-count'], }, count: { description: 'Total number of items', schema: { type: 'integer', minimum: 0, }, }, }, links: buildLinks(links), }, 400: components.responses[400], }, }); } export function getModel(model: GenericType, links: Links) { const entityName = getEntityName(model, true); return merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`GET_${entityName}`), summary: `Get a ${entityName}`, description: `Get a specific <code>${entityName}</code> uniquely identified by its <code>${model.getCorrelationField()}</code>.`, parameters: [ { in: 'path', name: model.getCorrelationField(), description: 'Correlation id', schema: c.COMPONENT_CORRELATION_ID, required: true, }, { in: 'header', name: 'decrypt', description: 'should we decipher the value', schema: { type: 'string', enum: ['true', 'false'], }, }, ], responses: { 200: { description: 'Entity successfully fetched', content: { [MIME_APPLICATION_JSON]: { schema: { $ref: `#/components/schemas/${entityName}`, }, }, }, links: buildLinks(links), }, 400: components.responses[400], 404: components.responses[404], }, }); } export function getModelAtVersion(model: GenericType, links: Links) { const entityName = getEntityName(model, true); return merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`${entityName}_AT_VERSION`), summary: `Get ${entityName} at version`, description: `Get a specific version for a <code>${entityName}</code>.`, parameters: [ { in: 'path', name: model.getCorrelationField(), description: 'Correlation id', schema: c.COMPONENT_CORRELATION_ID, required: true, }, { in: 'path', name: 'version', schema: { type: 'string', default: '0', }, required: true, description: 'Version', }, ], responses: { 200: { description: 'The model at the given version', content: { [MIME_APPLICATION_JSON]: { schema: { $ref: `#/components/schemas/${entityName}`, }, }, }, links: buildLinks(links), }, 400: components.responses[400], 404: components.responses[404], }, }); } export function restoreVersion(model: GenericType, links: Links) { const entityName = getEntityName(model, true); return merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`RESTORE_${entityName}_AT_VERSION`), summary: `Restore ${entityName} at version`, description: `Restore a specific version of the <code>${entityName}</code>.`, parameters: [ { in: 'path', name: model.getCorrelationField(), description: 'Correlation id', schema: c.COMPONENT_CORRELATION_ID, required: true, }, { in: 'path', name: 'version', schema: { type: 'integer', default: 0, }, required: true, description: 'Version', }, ], responses: { 200: { description: 'The model at the given version', content: { [MIME_APPLICATION_JSON]: { schema: { $ref: `#/components/schemas/${entityName}`, }, }, }, links: buildLinks(links), }, 400: components.responses[400], 404: components.responses[404], 409: components.responses[409], }, }); } export function getModelEvents( model: GenericType, links: Links, options: { skipCorrelationField: boolean } = { skipCorrelationField: false }, ) { const entityName = getEntityName(model, true); let operationId = eventNametoCamelCase(`${entityName}_EVENTS`); let summary = `Get a ${entityName} events`; let description = `Get all events attached to a <code>${entityName}</code>.`; let parameters = [ { in: 'path', name: model.getCorrelationField(), description: 'Correlation id', schema: c.COMPONENT_CORRELATION_ID, required: true, }, ]; if (options.skipCorrelationField === true) { operationId = eventNametoCamelCase(`${entityName}_ALL_EVENTS`); summary = 'Get all events'; description = `Get all events created for this kind of entities whatever the value of the correlation ID`; parameters = []; } return merge({}, defaultSchema(model), { operationId, summary, description, parameters: [ ...parameters, { in: 'header', name: 'page', ...components.headers['pagination-page'], }, { in: 'header', name: 'page-size', ...components.headers['pagination-size'], }, ], responses: { 200: { description: 'Entity events successfully fetched', content: { [MIME_APPLICATION_JSON]: { schema: { type: 'array', items: { type: 'object', required: ['v', 'type', 'version'], properties: { type: c.COMPONENT_EVENT_TYPE, v: c.COMPONENT_EVENT_TYPE_VERSION, version: c.COMPONENT_EVENT_VERSION, }, }, }, }, }, links: buildLinks(links), }, 400: components.responses[400], 404: components.responses[404], }, }); } export function createModelSnapshot(model: GenericType, links: Links) { const entityName = getEntityName(model, true); return merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`CREATE_${entityName}_SNAPSHOT`), summary: `Create snapshot`, description: `Create a snapshot of a <code>${entityName}</code> to keep a frozen version in database.`, parameters: [ { in: 'path', name: model.getCorrelationField(), description: 'Correlation id', schema: c.COMPONENT_CORRELATION_ID, required: true, }, { in: 'query', name: 'version', description: 'State version to snapshot', schema: { type: 'string', }, }, { in: 'query', name: 'clean', description: 'Remove events with version less than or equal the provided version', schema: { type: 'boolean', }, }, ], responses: { 200: { description: 'Snapshot successfully created', content: { [MIME_APPLICATION_JSON]: { schema: { $ref: `#/components/schemas/${entityName}`, }, }, }, links: buildLinks(links), }, 400: components.responses[400], 422: components.responses[422], }, }); } export function encrypt(model: GenericType, links: Links) { const originalSchema = model.getOriginalSchema(); const schema = model.getSchema(); const entityName = getEntityName(model, true); return merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`ENCRYPT_${entityName}`), summary: `Encrypt fields`, description: `Encrypt fields in <code>${entityName}</code>.`, requestBody: { content: { [MIME_APPLICATION_JSON]: { schema: { type: 'array', items: { type: 'object', properties: schema.model.properties, }, }, }, }, }, responses: { 200: { description: 'Encrypted fields', content: { [MIME_APPLICATION_JSON]: { schema: { type: 'array', items: { ...omit(get(originalSchema, 'model'), 'handler'), required: undefined, }, }, }, }, links: buildLinks(links), }, 400: components.responses[400], 422: components.responses[422], }, }); } export function decrypt(model: GenericType, links: Links) { const originalSchema = model.getOriginalSchema(); const schema = model.getSchema(); const entityName = getEntityName(model, true); return merge({}, defaultSchema(model), { operationId: eventNametoCamelCase(`DECRYPT_${entityName}`), summary: `Decrypt encrypted fields`, description: `Decrypt fields in <code>${entityName}</code>.`, requestBody: { content: { [MIME_APPLICATION_JSON]: { schema: { type: 'array', items: { type: 'object', properties: schema.model.properties, }, }, }, }, }, responses: { 200: { description: 'Decrypted fields', content: { [MIME_APPLICATION_JSON]: { schema: { type: 'array', items: { ...omit(get(originalSchema, 'model'), 'handler'), additionalProperties: true, required: undefined, }, }, }, }, links: buildLinks(links), }, 400: components.responses[400], 422: components.responses[422], }, }); }