UNPKG

@seamapi/blueprint

Version:

Build tools for the Seam API using this blueprint.

1,095 lines 46.3 kB
import { z } from 'zod'; import { findCommonOpenapiSchemaProperties } from './openapi/find-common-openapi-schema-properties.js'; import { flattenOpenapiSchema } from './openapi/flatten-openapi-schema.js'; import { EventResourceSchema, OpenapiOperationSchema, PropertySchema, ResourceSchema, } from './openapi/schemas.js'; import { CodeSampleDefinitionSchema, createCodeSample, createResourceSample, ResourceSampleDefinitionSchema, } from './samples/index.js'; import { mapOpenapiToSeamAuthMethod, } from './seam.js'; const paginationResponseKey = 'pagination'; export const TypesModuleSchema = z.object({ codeSampleDefinitions: z.array(CodeSampleDefinitionSchema).default([]), resourceSampleDefinitions: z .array(ResourceSampleDefinitionSchema) .default([]), // TODO: Import and use openapi zod schema here openapi: z.any(), schemas: z.record(z.string(), z.unknown()).optional().default({}), }); export const createBlueprint = async (typesModule, { formatCode = async (content) => content } = {}) => { const { schemas, codeSampleDefinitions, resourceSampleDefinitions } = TypesModuleSchema.parse(typesModule); // TODO: Move openapi to TypesModuleSchema const openapi = typesModule.openapi; const validActionAttemptTypes = extractValidActionAttemptTypes(openapi.components.schemas); const validResourceTypes = extractValidResourceTypes(openapi.components.schemas); const context = { codeSampleDefinitions, resourceSampleDefinitions, formatCode, schemas, validActionAttemptTypes, validResourceTypes, }; const routes = await createRoutes(openapi.paths, context); const namespaces = createNamespaces(routes); const pagination = openapi.components.schemas[paginationResponseKey]; const openapiSchemas = Object.fromEntries(Object.entries(openapi.components.schemas).filter(([k]) => k !== paginationResponseKey)); const resources = await createResources(openapiSchemas, routes, context); const actionAttempts = await createActionAttempts(openapi.components.schemas, routes, context); const events = await createEvents(openapiSchemas, routes, context); assertSeamPathsAreUndocumented({ routes, namespaces, resources, events, actionAttempts, }); assertDocumentedEndpointsDontReferenceUndocumentedResources({ routes, resources, }); return { title: openapi.info.title, routes, namespaces, resources, pagination: createPagination(pagination, openapiSchemas), events, actionAttempts, }; }; const isSeamPath = (path) => path === '/seam' || path.startsWith('/seam/'); const assertSeamPathsAreUndocumented = ({ routes, namespaces, resources, events, actionAttempts, }) => { const offenders = [ ...routes.flatMap((route) => { const routeOffenders = isSeamPath(route.path) && !route.isUndocumented ? [`route ${route.path}`] : []; const endpointOffenders = route.endpoints.flatMap((endpoint) => isSeamPath(endpoint.path) && !endpoint.isUndocumented ? [`endpoint ${endpoint.path}`] : []); return [...routeOffenders, ...endpointOffenders]; }), ...namespaces.flatMap((namespace) => isSeamPath(namespace.path) && !namespace.isUndocumented ? [`namespace ${namespace.path}`] : []), ...resources.flatMap((resource) => isSeamPath(resource.routePath) && !resource.isUndocumented ? [`resource ${resource.routePath}`] : []), ...events.flatMap((event) => isSeamPath(event.routePath) && !event.isUndocumented ? [`event ${event.routePath}`] : []), ...actionAttempts.flatMap((actionAttempt) => isSeamPath(actionAttempt.routePath) && !actionAttempt.isUndocumented ? [`action_attempt ${actionAttempt.routePath}`] : []), ]; if (offenders.length > 0) { throw new Error(`All /seam entries must be marked undocumented. Found: ${offenders.join(', ')}`); } }; const assertDocumentedEndpointsDontReferenceUndocumentedResources = ({ routes, resources, }) => { const undocumentedResourceTypes = new Set(resources.filter((r) => r.isUndocumented).map((r) => r.resourceType)); const offenders = []; for (const route of routes) { for (const endpoint of route.endpoints) { if (endpoint.isUndocumented) continue; if (endpoint.response.responseType === 'void') continue; if (!('resourceType' in endpoint.response)) continue; const { resourceType } = endpoint.response; if (undocumentedResourceTypes.has(resourceType)) { offenders.push(`${endpoint.path} references undocumented resource '${resourceType}'`); } } } if (offenders.length > 0) { throw new Error(`Documented endpoints must not reference undocumented resources. Found:\n${offenders.join('\n')}`); } }; const extractValidActionAttemptTypes = (schemas) => { const actionAttemptSchema = schemas['action_attempt']; if (actionAttemptSchema == null || typeof actionAttemptSchema !== 'object' || !('oneOf' in actionAttemptSchema) || !Array.isArray(actionAttemptSchema.oneOf)) { return []; } const processedActionAttemptTypes = new Set(); actionAttemptSchema.oneOf.forEach((schema) => { const actionType = schema.properties?.['action_type']?.enum?.[0]; if (typeof actionType === 'string') { processedActionAttemptTypes.add(actionType); } }); return Array.from(processedActionAttemptTypes); }; const extractValidResourceTypes = (schemas) => { return Object.keys(schemas); }; const createRoutes = async (paths, context) => { const routeMap = new Map(); const pathEntries = Object.entries(paths); for (const [path, pathItem] of pathEntries) { const namespacePath = getNamespacePath(path, paths); const route = await createRoute(namespacePath, path, pathItem, context); const existingRoute = routeMap.get(route.path); if (existingRoute != null) { existingRoute.endpoints.push(...route.endpoints); continue; } routeMap.set(route.path, route); } const routes = Array.from(routeMap.values()); return routes .map(addIsDeprecatedToRoute) .map(addIsUndocumentedToRoute) .map(addIsDraftToRoute); }; const getNamespacePath = (path, paths) => { // Hold namespace in array to allow nested namespaces // e.g. namespace for `/foo/bar/baz/get` = `/foo/bar` const namespace = []; const pathParts = path.split('/').filter((path) => Boolean(path)); const pathKeys = Object.keys(paths); for (const [index, part] of pathParts.entries()) { // Namespaces must be consecutive. If there was a path with an endpoint // previously, then this part is not in the namespace. if (namespace.length !== index) { continue; } // An endpoint is a route that ends without further paths. i.e., ends in // a letter (not slash). const endpoints = pathKeys.filter((key) => new RegExp(`^/${[...namespace, part].join('/')}/\\w+$`).test(key)); if (endpoints.length === 0) { namespace.push(part); } } if (namespace.length === 0) { return null; } return `/${namespace.join('/')}`; }; const createRoute = async (namespacePath, path, pathItem, context) => { const pathParts = path.split('/'); const routePath = `/${pathParts.slice(1, -1).join('/')}`; const name = pathParts.at(-2); if (name == null) { throw new Error(`Could not resolve name for route at ${path}`); } const endpoint = await createEndpoint(path, pathItem, context); return { path: routePath, name, namespacePath, endpoints: endpoint != null ? [endpoint] : [], parentPath: getParentPath(routePath), isUndocumented: false, isDeprecated: false, isDraft: false, }; }; const addIsDeprecatedToRoute = (route) => ({ ...route, isDeprecated: route.endpoints.every((endpoint) => endpoint.isDeprecated), }); const addIsUndocumentedToRoute = (route) => ({ ...route, isUndocumented: route.endpoints.every((endpoint) => endpoint.isUndocumented), }); const addIsDraftToRoute = (route) => ({ ...route, isDraft: route.endpoints.every((endpoint) => endpoint.isDraft), }); const createNamespaces = (routes) => { const namespacePaths = [ ...new Set(routes.flatMap((r) => (r.namespacePath == null ? [] : [r.namespacePath]))), ]; return namespacePaths.map((path) => { const namespaceRoutes = routes.filter((r) => r.namespacePath === path); const pathParts = path.split('/'); const name = pathParts.at(-1); if (name == null) { throw new Error(`Could not resolve name for route at ${path}`); } const isDeprecated = namespaceRoutes.every((r) => r.isDeprecated); const isUndocumented = namespaceRoutes.every((r) => r.isUndocumented); const isDraft = namespaceRoutes.every((r) => r.isDraft); return { name, path, parentPath: getParentPath(path), isDeprecated, isUndocumented, isDraft, }; }); }; const createEndpoint = async (path, pathItem, context) => { const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; const validOperations = Object.entries(pathItem).filter(([method, operation]) => validMethods.includes(method.toUpperCase()) && typeof operation === 'object' && operation !== null); const supportedMethods = validOperations.map(([method]) => method.toUpperCase()); const validOperation = validOperations.find(([m]) => m === 'post'); if (validOperation == null) { throw new Error(`No valid post operation found for ${path}`); } const [_, operation] = validOperation; return await createEndpointFromOperation(supportedMethods, operation, path, context); }; const createEndpointFromOperation = async (methods, operation, path, context) => { const pathParts = path.split('/'); const endpointPath = `/${pathParts.slice(1).join('/')}`; const name = pathParts.at(-1); if (name == null) { throw new Error(`Could not resolve name for endpoint at ${path}`); } const parsedOperation = OpenapiOperationSchema.parse(operation, { path: pathParts, }); const title = parsedOperation['x-title']; const description = normalizeDescription(parsedOperation.description); const isUndocumented = parsedOperation['x-undocumented'].length > 0; const undocumentedMessage = parsedOperation['x-undocumented']; const deprecationMessage = parsedOperation['x-deprecated']; const isDeprecated = deprecationMessage.length > 0; const isDraft = parsedOperation['x-draft'].length > 0; const draftMessage = parsedOperation['x-draft']; const request = createRequest(methods, operation, path); const { hasPagination, ...response } = createResponse(operation, path, context); const operationAuthMethods = parsedOperation.security.map((securitySchema) => { const [authMethod = ''] = Object.keys(securitySchema); return authMethod; }); const endpointAuthMethods = [ ...new Set(operationAuthMethods .map(mapOpenapiToSeamAuthMethod) .filter((authMethod) => authMethod != null)), ]; const workspaceScope = getWorkspaceScope(operationAuthMethods); const endpoint = { title, name, path: endpointPath, parentPath: getParentPath(endpointPath), description, isDeprecated, deprecationMessage, isUndocumented, undocumentedMessage, isDraft, draftMessage, response, request, hasPagination, authMethods: endpointAuthMethods, workspaceScope, }; return { ...endpoint, codeSamples: await Promise.all(context.codeSampleDefinitions .filter(({ request }) => request.path === endpointPath) .map(async (codeSampleDefinition) => await createCodeSample(codeSampleDefinition, { endpoint, formatCode: context.formatCode, }))), }; }; export const getWorkspaceScope = (authMethods) => { const hasWorkspaceUnscoped = authMethods.some((method) => method.endsWith('_without_workspace')); const workspaceScopedAuthMethods = [ 'api_key', 'client_session', 'client_session_with_customer', 'console_session_token_with_workspace', 'pat_with_workspace', 'publishable_key', ]; const hasWorkspaceScoped = authMethods.some((method) => workspaceScopedAuthMethods.includes(method)); const hasNoAuthMethods = !hasWorkspaceUnscoped && !hasWorkspaceScoped; if (hasNoAuthMethods) return 'none'; const hasOnlyUnscopedAuth = hasWorkspaceUnscoped && !hasWorkspaceScoped; if (hasOnlyUnscopedAuth) return 'none'; const hasBothScopedAndUnscoped = hasWorkspaceUnscoped && hasWorkspaceScoped; if (hasBothScopedAndUnscoped) return 'optional'; const hasOnlyScopedAuth = !hasWorkspaceUnscoped && hasWorkspaceScoped; if (hasOnlyScopedAuth) return 'required'; return 'none'; }; const createRequest = (methods, operation, path) => { if (methods.length === 0) { // eslint-disable-next-line no-console console.warn(`At least one HTTP method should be specified for ${path}`); } if (methods.length > 2) { // eslint-disable-next-line no-console console.warn(`More than two methods detected for ${path}. Was this intended?`); } if (!methods.includes('POST')) { throw new Error(`POST method is missing for ${path}`); } const semanticMethod = getSemanticMethod(methods); const preferredMethod = getPreferredMethod(methods, semanticMethod, operation); return { methods, semanticMethod, preferredMethod, parameters: createRequestBody(operation, path), }; }; const createRequestBody = (operation, path) => { // This should be done by the createParameters but for some reason it's not // TODO: remove this in favour of using createParameters if (!('requestBody' in operation) || operation.requestBody === undefined) { return []; } const { requestBody } = operation; const jsonSchema = requestBody.content?.['application/json']?.schema; if (jsonSchema == null) return []; const flattenedSchema = flattenOpenapiSchema(jsonSchema); if (flattenedSchema.type !== 'object' || flattenedSchema.properties == null) { return []; } return createParameters(flattenedSchema.properties, path, flattenedSchema.required); }; const createParameters = (properties, path, requiredParameters = []) => Object.entries(properties) .map(([name, property]) => { // Don't flatten discriminated arrays as they are handled separately in createParameter if (property.type === 'array' && 'items' in property && 'discriminator' in property.items) { return [name, property]; } return [name, flattenOpenapiSchema(property)]; }) .filter(([name, property]) => { if (property.type == null) { // eslint-disable-next-line no-console console.warn(`The ${name} property for ${path} will not be documented since it does not define a type.`); return false; } return true; }) .map(([name, property]) => createParameter(name, property, path, requiredParameters)); const createParameter = (name, property, path, requiredParameters) => { const parsedProp = PropertySchema.parse(property, { path: [...path.split('/'), name], }); const baseParam = { name, description: normalizeDescription(String(parsedProp.description ?? '')), isRequired: requiredParameters.includes(name), isDeprecated: parsedProp['x-deprecated'].length > 0, deprecationMessage: parsedProp['x-deprecated'], isUndocumented: parsedProp['x-undocumented'].length > 0, undocumentedMessage: parsedProp['x-undocumented'], isDraft: parsedProp['x-draft'].length > 0, draftMessage: parsedProp['x-draft'], hasDefault: 'default' in parsedProp, }; if (baseParam.hasDefault) { baseParam.default = parsedProp.default; } switch (parsedProp.type) { case 'string': if (parsedProp.enum !== undefined) { return { ...baseParam, format: 'enum', jsonType: 'string', values: parsedProp.enum.map((value) => { const enumValue = parsedProp['x-enums']?.[String(value)]; if (parsedProp['x-enums'] != null && enumValue == null) { throw new Error(`Missing enum value definition in x-enums for "${String(value)}"`); } return { name: String(value), description: normalizeDescription(String(enumValue?.description ?? '')), isDeprecated: Boolean(enumValue?.deprecated?.length ?? 0), deprecationMessage: enumValue?.deprecated ?? '', isUndocumented: Boolean(enumValue?.undocumented?.length ?? 0), undocumentedMessage: enumValue?.undocumented ?? '', isDraft: Boolean(enumValue?.draft?.length ?? 0), draftMessage: enumValue?.draft ?? '', }; }), }; } if (parsedProp.format === 'date-time') { return { ...baseParam, format: 'datetime', jsonType: 'string' }; } if (parsedProp.format === 'uuid') { return { ...baseParam, format: 'id', jsonType: 'string' }; } return { ...baseParam, format: 'string', jsonType: 'string' }; case 'boolean': return { ...baseParam, format: 'boolean', jsonType: 'boolean' }; case 'array': { return createArrayParameter(baseParam, property, path); } case 'object': if (property.properties !== undefined) { return { ...baseParam, format: 'object', jsonType: 'object', parameters: createParameters(property.properties, path), }; } return { ...baseParam, format: 'record', jsonType: 'object' }; case 'number': case 'integer': return { ...baseParam, format: 'number', jsonType: 'number', }; default: throw new Error(`Unsupported property type: ${parsedProp.type}`); } }; const createArrayParameter = (baseParam, property, path) => { function createListParameter(format, extraProps = {}) { return { ...baseParam, format: 'list', jsonType: 'array', itemFormat: format, ...extraProps, }; } const fallbackListParameter = createListParameter('record'); if (property.items == null) { return fallbackListParameter; } if ('oneOf' in property.items) { if (!property.items.oneOf.every((schema) => schema.type === 'object')) { return fallbackListParameter; } if (property.items.discriminator?.propertyName == null) { throw new Error(`Missing discriminator property name for ${baseParam.name} in ${path}`); } return createListParameter('discriminated_object', { discriminator: property.items.discriminator.propertyName, variants: property.items.oneOf.map((schema) => ({ parameters: createParameters(schema.properties ?? {}, path, schema.required ?? []), description: normalizeDescription(schema.description ?? ''), })), }); } const itemParameter = createParameter('item', property.items, path, []); switch (itemParameter.format) { case 'string': return createListParameter('string'); case 'number': return createListParameter('number'); case 'boolean': return createListParameter('boolean'); case 'datetime': return createListParameter('datetime'); case 'id': return createListParameter('id'); case 'enum': return createListParameter('enum', { itemEnumValues: itemParameter.values, }); case 'object': return createListParameter('object', { itemParameters: itemParameter.parameters, }); case 'record': return createListParameter('record'); default: return fallbackListParameter; } }; const createPagination = (schema, schemas) => { if (schema == null) return null; return { responseKey: paginationResponseKey, description: normalizeDescription(schema.description ?? ''), properties: createProperties(schema.properties ?? {}, [paginationResponseKey], [], schemas), }; }; export const createResources = async (schemas, routes, context) => { const resources = []; for (const [schemaName, schema] of Object.entries(schemas)) { const { success: isValidEventSchema, data: parsedEvent } = EventResourceSchema.safeParse(schema); if (isValidEventSchema) { const commonProperties = findCommonOpenapiSchemaProperties(parsedEvent.oneOf); const eventSchema = { 'x-route-path': parsedEvent['x-route-path'], properties: commonProperties, type: 'object', }; const resource = await createResource(schemaName, eventSchema, schemas, routes, context); resources.push(resource); continue; } const { success: isValidResourceSchema } = ResourceSchema.safeParse(schema); if (isValidResourceSchema) { const resource = await createResource(schemaName, schema, schemas, routes, context); resources.push(resource); continue; } } return resources; }; const createResource = async (schemaName, schema, schemas, routes, context) => { const routePath = validateRoutePath(schemaName, schema['x-route-path'], routes); const propertyGroups = getPropertyGroups(schema); const resourceType = schemaName; const resource = { resourceType, properties: createProperties(schema.properties ?? {}, [schemaName], propertyGroups, schemas), description: normalizeDescription(schema.description ?? ''), isDeprecated: schema.deprecated ?? false, routePath, deprecationMessage: schema['x-deprecated'] ?? '', isUndocumented: (schema['x-undocumented'] ?? '').length > 0, undocumentedMessage: schema['x-undocumented'] ?? '', isDraft: (schema['x-draft'] ?? '').length > 0, draftMessage: schema['x-draft'] ?? '', propertyGroups, }; return { ...resource, resourceSamples: await Promise.all(context.resourceSampleDefinitions .filter((resourceSample) => resourceSample.resource_type === resourceType) .map(async (resourceSampleDefinition) => await createResourceSample(resourceSampleDefinition, { resource, schemas: context.schemas, formatCode: context.formatCode, }))), }; }; const validateRoutePath = (resourceName, routePath, routes) => { if (routePath == null || routePath.length === 0) { throw new Error(`Resource ${resourceName} is missing a route path`); } if (!routes.some((r) => r.path === routePath)) { throw new Error(`Route path ${routePath} not found in routes`); } return routePath; }; const getPropertyGroups = (schema) => { const rawPropertyGroups = schema['x-property-groups'] ?? {}; const propertyGroups = {}; for (const [key, group] of Object.entries(rawPropertyGroups)) { propertyGroups[key] = { name: group.name, propertyGroupKey: key, }; } return Object.values(propertyGroups); }; const getVariantGroups = (schema) => { const rawVariantGroups = schema['x-variant-groups'] ?? {}; const variantGroups = {}; for (const [key, group] of Object.entries(rawVariantGroups)) { variantGroups[key] = { name: group.name, variantGroupKey: key, }; } return Object.values(variantGroups); }; const createResponse = (operation, path, context) => { if (!('responses' in operation) || operation.responses == null) { throw new Error(`Missing responses in operation for ${operation.operationId}`); } const parsedOperation = OpenapiOperationSchema.parse(operation, { path: [...path.split('/')], }); const { responses } = operation; const okResponse = responses['200']; if (typeof okResponse !== 'object' || okResponse == null) { return { responseType: 'void', description: normalizeDescription('Unknown'), hasPagination: false, }; } const description = normalizeDescription(okResponse.description ?? ''); if (!('x-response-key' in parsedOperation)) { throw new Error(`Missing responseKey for ${path}`); } const responseKey = parsedOperation['x-response-key']; if (responseKey === null) { return { responseType: 'void', description, hasPagination: false, }; } if (responseKey == null) { throw new Error(`Missing responseKey for ${path}`); } const content = 'content' in okResponse ? okResponse.content : null; if (typeof content !== 'object' || content === null) { return { responseType: 'void', description, hasPagination: false, }; } const jsonContent = 'application/json' in content ? content['application/json'] : null; if (jsonContent === null) { return { responseType: 'void', description, hasPagination: false, }; } const schema = 'schema' in jsonContent ? jsonContent.schema : null; if (schema === null) { return { responseType: 'void', description, hasPagination: false, }; } if ('type' in schema && 'properties' in schema && schema.type === 'object' && typeof schema.properties === 'object' && schema.properties !== null) { const { properties } = schema; if (!(responseKey in properties)) { throw new Error(`Response key '${responseKey}' not found in response schema for ${path}`); } const refKey = responseKey; if (refKey != null && properties[refKey] != null) { const props = schema.properties[refKey]; const refString = props?.$ref ?? props?.items?.$ref; const resourceType = refString?.split('/').at(-1) ?? 'unknown'; const actionAttemptType = validateActionAttemptType(parsedOperation['x-action-attempt-type'] ?? null, responseKey, path, resourceType, context.validActionAttemptTypes); const batchKeys = parsedOperation['x-batch-keys']; const batchResourceTypes = batchKeys ? batchKeys.flatMap((key) => { const resourceRef = props?.properties?.[key]?.items?.$ref; if (resourceRef == null) { return []; } // get last part of $ref. e.g. { '$ref': '#/components/schemas/space' } const batchResourceType = resourceRef.split('/').at(-1); if (batchResourceType == null) { return []; } if (!context.validResourceTypes.includes(batchResourceType)) { throw new Error(`Unknown batch resource type '${batchResourceType}' referenced by ${path}`); } return [ { batchKey: key, resourceType: batchResourceType, }, ]; }) : null; return { responseType: props?.type === 'array' ? 'resource_list' : 'resource', responseKey: refKey, resourceType, description, hasPagination: (paginationResponseKey in properties && properties[paginationResponseKey]?.$ref?.endsWith(`/${paginationResponseKey}`)) ?? false, actionAttemptType, batchResourceTypes: batchResourceTypes ?? null, }; } } return { responseType: 'void', description: normalizeDescription('Unknown'), hasPagination: false, }; }; const validateActionAttemptType = (actionAttemptType, responseKey, path, resourceType, validActionAttemptTypes) => { const excludedPaths = ['/action_attempts']; const isPathExcluded = excludedPaths.some((p) => path.startsWith(p)); if (resourceType !== 'action_attempt') { return null; } if (actionAttemptType == null && responseKey === 'action_attempt' && !isPathExcluded) { throw new Error(`Missing action_attempt_type for path ${path}`); } if (actionAttemptType != null && !validActionAttemptTypes.includes(actionAttemptType)) { throw new Error(`Invalid action_attempt_type '${actionAttemptType}' for path ${path}`); } return actionAttemptType; }; export const createProperties = (properties, parentPaths, propertyGroups, schemas) => Object.entries(properties) .map(([name, property]) => { // Don't flatten discriminated arrays as they are handled separately in createProperty if (property.type === 'array' && 'items' in property && 'discriminator' in property.items) { return [name, property]; } return [name, flattenOpenapiSchema(property)]; }) .filter(([name, property]) => { if (property.type == null) { // eslint-disable-next-line no-console console.warn(`The ${name} property for ${parentPaths.join('.')} will not be documented since it does not define a type.`); return false; } return true; }) .map(([name, prop]) => createProperty(name, prop, parentPaths, propertyGroups, schemas)); const createProperty = (name, prop, parentPaths, propertyGroups, schemas) => { const parsedProp = parsePropertySchema(name, prop, parentPaths, schemas); if (name.startsWith('ext_')) { throw new Error(`Property cannot start with ext_ : ${parentPaths.join('.')}.${name}`); } const propertyGroupKey = parsedProp['x-property-group-key']; validateGroupKey(propertyGroupKey, name, parentPaths, propertyGroups); const baseProperty = { name, description: normalizeDescription(String(parsedProp.description ?? '')), isDeprecated: parsedProp['x-deprecated'].length > 0, deprecationMessage: parsedProp['x-deprecated'], isUndocumented: parsedProp['x-undocumented'].length > 0, undocumentedMessage: parsedProp['x-undocumented'], isDraft: parsedProp['x-draft'].length > 0, draftMessage: parsedProp['x-draft'], propertyGroupKey: propertyGroupKey === '' ? null : propertyGroupKey, }; if (parentPaths.includes('batch') && prop.type === 'array' && prop.items?.$ref) { const refPath = prop.items.$ref; const resourceType = refPath.split('/').pop(); if (resourceType) { const batchResourceProperty = { ...baseProperty, format: 'record', jsonType: 'object', resourceType, }; return batchResourceProperty; } } switch (parsedProp.type) { case 'string': if (parsedProp.enum !== undefined) { return { ...baseProperty, format: 'enum', jsonType: 'string', values: parsedProp.enum.map((value) => { const enumValue = parsedProp['x-enums']?.[String(value)]; if (parsedProp['x-enums'] != null && enumValue == null) { throw new Error(`Missing enum value definition in x-enums for "${String(value)}"`); } return { name: String(value), description: normalizeDescription(String(enumValue?.description ?? '')), isDeprecated: Boolean(enumValue?.deprecated?.length ?? 0), deprecationMessage: enumValue?.deprecated ?? '', isUndocumented: Boolean(enumValue?.undocumented?.length ?? 0), undocumentedMessage: enumValue?.undocumented ?? '', isDraft: Boolean(enumValue?.draft?.length ?? 0), draftMessage: enumValue?.draft ?? '', }; }), }; } if (parsedProp.format === 'date-time') { return { ...baseProperty, format: 'datetime', jsonType: 'string' }; } if (parsedProp.format === 'uuid') { return { ...baseProperty, format: 'id', jsonType: 'string' }; } return { ...baseProperty, format: 'string', jsonType: 'string' }; case 'boolean': return { ...baseProperty, format: 'boolean', jsonType: 'boolean' }; case 'array': { return createArrayProperty(baseProperty, prop, parentPaths, schemas); } case 'object': if (prop.properties !== undefined) { const nestedPropertyGroups = getPropertyGroups(prop); return { ...baseProperty, format: 'object', jsonType: 'object', propertyGroups: nestedPropertyGroups, properties: createProperties(prop.properties, [...parentPaths, name], nestedPropertyGroups, schemas), }; } return { ...baseProperty, format: 'record', jsonType: 'object' }; case 'number': case 'integer': return { ...baseProperty, format: 'number', jsonType: 'number', }; default: throw new Error(`Unsupported property type: ${parsedProp.type}`); } }; const validateGroupKey = (groupKey, propertyName, parentPaths, groups) => { if (groupKey == null || groupKey.length === 0) return; const resourceName = parentPaths.at(0); if (resourceName == null) { throw new Error(`Missing resource name for property "${propertyName}" in ${parentPaths.join('.')}`); } const validGroupKeys = groups.map((g) => { if ('propertyGroupKey' in g) return g.propertyGroupKey; if ('variantGroupKey' in g) return g.variantGroupKey; throw new Error('Expected propertyGroupKey or variantGroupKey'); }); if (validGroupKeys.length === 0) { throw new Error(`The "${propertyName}" has group ${groupKey} but ${parentPaths.join('.')} does not define any groups.`); } if (!validGroupKeys.includes(groupKey)) { throw new Error(`Invalid property group "${groupKey}" for property "${propertyName}" in resource "${resourceName}". Valid groups are: ${validGroupKeys.join(', ')}`); } }; const createArrayProperty = (baseProperty, prop, parentPaths, schemas) => { function createListProperty(format, extraProps = {}) { return { ...baseProperty, format: 'list', jsonType: 'array', itemFormat: format, ...extraProps, }; } const fallbackListProperty = createListProperty('record'); if (prop.items == null) { return fallbackListProperty; } if ('oneOf' in prop.items) { if (!prop.items.oneOf.every((schema) => schema.type === 'object')) { return fallbackListProperty; } if (prop.items.discriminator?.propertyName == null) { throw new Error(`Missing discriminator property name for ${baseProperty.name} in ${parentPaths.join('.')}`); } const variantGroups = getVariantGroups(prop); return createListProperty('discriminated_object', { discriminator: prop.items.discriminator.propertyName, variantGroups, variants: prop.items.oneOf.map((schema) => { const errorCode = schema.properties?.['error_code']?.enum?.[0]; if (errorCode != null && !('x-resource-type' in schema && schema['x-resource-type'] != null && schema['x-resource-type'].length > 0)) { throw new Error(`Missing resource_type for error code ${errorCode}`); } const resourceType = schema['x-resource-type'] ?? null; const variantGroupKey = schema['x-variant-group-key'] ?? null; validateGroupKey(variantGroupKey, baseProperty.name, parentPaths, variantGroups); return { resourceType, variantGroupKey, properties: createProperties(schema.properties ?? {}, [...parentPaths, baseProperty.name], [], schemas), description: normalizeDescription(schema.description ?? ''), }; }), }); } const itemProperty = createProperty('item', prop.items, [...parentPaths, baseProperty.name], [], schemas); switch (itemProperty.format) { case 'string': return createListProperty('string'); case 'number': return createListProperty('number'); case 'boolean': return createListProperty('boolean'); case 'datetime': return createListProperty('datetime'); case 'id': return createListProperty('id'); case 'enum': return createListProperty('enum', { itemEnumValues: itemProperty.values, }); case 'object': if (!('properties' in itemProperty)) { throw new Error(`Missing properties for object in ${parentPaths.join('.')}`); } return createListProperty('object', { itemProperties: itemProperty.properties, }); case 'record': return createListProperty('record'); default: return fallbackListProperty; } }; const parsePropertySchema = (name, prop, parentPaths, schemas) => { if (parentPaths.includes('batch')) { return parseBatchPropertySchema(name, prop, parentPaths, schemas); } return PropertySchema.parse(prop, { path: [...parentPaths, name], }); }; /** * Parses the correct schema for a batch property. Batch properties have the following path: * `batch.some_resources` where the correct schema should be for `some_resource`. Try to * extract the schema name from the $ref path and parse it from global openapi schemas. */ const parseBatchPropertySchema = (name, prop, parentPaths, schemas) => { const refPath = prop.items?.$ref; // Extract schema name from ref like '#/components/schemas/access_code' -> 'access_code' const schemaName = refPath?.split('/').pop(); if (schemaName == null || schemas[schemaName] == null) { throw new Error(`Missing $ref schema "${schemaName}" for property "batch.${name}"`); } const referencedSchema = schemas[schemaName]; return PropertySchema.parse(referencedSchema, { path: [...parentPaths, name], }); }; export const getSemanticMethod = (methods) => { if (methods.length === 1) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return methods[0]; } const priorityOrder = ['PUT', 'PATCH', 'GET', 'DELETE', 'POST']; for (const method of priorityOrder) { if (methods.includes(method)) { return method; } } return 'POST'; }; export const getPreferredMethod = (methods, semanticMethod, operation) => { if (methods.length === 1) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return methods[0]; } if (methods.includes('POST')) { if (semanticMethod === 'GET' || semanticMethod === 'DELETE') { const hasComplexParameters = (operation.parameters?.some((param) => param.schema?.type === 'array' || param.schema?.type === 'object') ?? false) || operation.requestBody?.content?.['application/json']?.schema?.type === 'object'; if (hasComplexParameters) { return 'POST'; } } } return semanticMethod; }; const createEvents = async (schemas, routes, context) => { const eventSchema = schemas['event']; if (eventSchema == null || typeof eventSchema !== 'object' || !('oneOf' in eventSchema) || !Array.isArray(eventSchema.oneOf)) { return []; } const events = await Promise.all(eventSchema.oneOf.map(async (schema) => { if (typeof schema !== 'object' || schema.properties?.['event_type']?.enum?.[0] == null) { return null; } const eventType = schema.properties['event_type'].enum[0]; if (!('x-route-path' in schema && schema['x-route-path'].length > 0)) { throw new Error(`Missing route_path for event type ${eventType}`); } const resource = await createResource('event', schema, schemas, routes, context); return { ...resource, eventType, resourceSamples: resource.resourceSamples.filter((resourceSample) => resourceSample.properties['event_type'] === eventType), }; })); return events.filter((event) => event !== null); }; const createActionAttempts = async (schemas, routes, context) => { const actionAttemptSchema = schemas['action_attempt']; if (actionAttemptSchema == null || typeof actionAttemptSchema !== 'object' || !('oneOf' in actionAttemptSchema) || !Array.isArray(actionAttemptSchema.oneOf)) { return []; } const schemasByActionType = new Map(); for (const schema of actionAttemptSchema.oneOf) { if (typeof schema !== 'object' || schema.properties?.['action_type']?.enum?.[0] == null) { continue; } const actionType = schema.properties['action_type'].enum[0]; const currentSchemas = schemasByActionType.get(actionType); if (currentSchemas == null) { schemasByActionType.set(actionType, [schema]); } else { currentSchemas.push(schema); } } return await Promise.all(Array.from(schemasByActionType.entries()).map(async ([actionType, actionAttemptSchemas]) => { const mergedProperties = {}; const allPropertyKeys = new Set(); for (const schema of actionAttemptSchemas) { if (schema.properties != null) { Object.keys(schema.properties).forEach((key) => { allPropertyKeys.add(key); }); } } for (const propKey of allPropertyKeys) { const propDefinitions = actionAttemptSchemas .filter((schema) => schema.properties?.[propKey] != null) .map((schema) => schema.properties?.[propKey]); if (propDefinitions.length === 0) continue; const nonNullableDefinition = propDefinitions.find((prop) => { if (prop == null) return false; return !('nullable' in prop && prop.nullable === true); }); mergedProperties[propKey] = nonNullableDefinition ?? propDefinitions[0] ?? {}; } // Ensure standard status field mergedProperties['status'] = { ...mergedProperties['status'], type: 'string', enum: ['success', 'pending', 'error'], }; const schemaWithMergedProperties = { ...(actionAttemptSchema['x-route-path'] != null && { 'x-route-path': actionAttemptSchema['x-route-path'], }), ...actionAttemptSchemas[0], properties: mergedProperties, }; const resource = await createResource('action_attempt', schemaWithMergedProperties, schemas, routes, context); return { ...resource, resourceType: 'action_attempt', actionAttemptType: actionType, resourceSamples: resource.resourceSamples.filter((resourceSample) => resourceSample.properties['action_type'] === actionType), }; })); }; const getParentPath = (path) => path.split('/').length === 2 ? null : path.split('/').slice(0, -1).join('/'); const normalizeDescription = (content) => content .split('\n\n') .map((s) => s.trim()) .join('\n\n'); //# sourceMappingURL=blueprint.js.map