UNPKG

@seamapi/blueprint

Version:

Build tools for the Seam API using this blueprint.

1,890 lines (1,636 loc) 56.8 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 type { Openapi, OpenapiAuthMethod, OpenapiOperation, OpenapiPathItem, OpenapiPaths, OpenapiSchema, } from './openapi/types.js' import { type CodeSample, type CodeSampleDefinition, CodeSampleDefinitionSchema, createCodeSample, createResourceSample, type ResourceSample, type ResourceSampleDefinition, ResourceSampleDefinitionSchema, type SyntaxName, } from './samples/index.js' import { mapOpenapiToSeamAuthMethod, type SeamAuthMethod, type SeamWorkspaceScope, } from './seam.js' const paginationResponseKey = 'pagination' export interface Blueprint { title: string routes: Route[] namespaces: Namespace[] resources: Resource[] pagination: Pagination | null events: EventResource[] actionAttempts: ActionAttempt[] } export interface Route { path: string name: string namespacePath: string | null endpoints: Endpoint[] parentPath: string | null isUndocumented: boolean isDeprecated: boolean isDraft: boolean } export interface Resource { resourceType: string properties: Property[] description: string routePath: string isDeprecated: boolean deprecationMessage: string isUndocumented: boolean undocumentedMessage: string isDraft: boolean draftMessage: string propertyGroups: PropertyGroup[] resourceSamples: ResourceSample[] } export interface PropertyGroup { name: string propertyGroupKey: string } export interface VariantGroup { name: string variantGroupKey: string } export interface Pagination { properties: Property[] description: string responseKey: string } export interface EventResource extends Resource { resourceType: 'event' eventType: string } export interface ActionAttempt extends Resource { resourceType: 'action_attempt' actionAttemptType: string } export interface Namespace { name: string path: string parentPath: string | null isDeprecated: boolean isUndocumented: boolean isDraft: boolean } export interface Endpoint { title: string path: string name: string parentPath: string | null description: string isDeprecated: boolean deprecationMessage: string isUndocumented: boolean undocumentedMessage: string isDraft: boolean draftMessage: string request: Request response: Response hasPagination: boolean codeSamples: CodeSample[] authMethods: SeamAuthMethod[] workspaceScope: SeamWorkspaceScope } interface BaseParameter { name: string description: string isRequired: boolean isDeprecated: boolean deprecationMessage: string isUndocumented: boolean undocumentedMessage: string isDraft: boolean draftMessage: string hasDefault: boolean } interface StringParameter extends BaseParameter { format: 'string' jsonType: 'string' default?: string | null } interface NumberParameter extends BaseParameter { format: 'number' jsonType: 'number' default?: number | null } interface EnumParameter extends BaseParameter { format: 'enum' jsonType: 'string' values: EnumValue[] default?: string | null } interface RecordParameter extends BaseParameter { format: 'record' jsonType: 'object' } interface BaseListParameter extends BaseParameter { format: 'list' jsonType: 'array' } type ListParameter = | StringListParameter | NumberListParameter | BooleanListParameter | DatetimeListParameter | IdListParameter | EnumListParameter | ObjectListParameter | RecordListParameter | DiscriminatedListParameter interface StringListParameter extends BaseListParameter { itemFormat: 'string' default?: string[] } interface NumberListParameter extends BaseListParameter { itemFormat: 'number' default?: number[] } interface BooleanListParameter extends BaseListParameter { itemFormat: 'boolean' default?: boolean[] } interface DatetimeListParameter extends BaseListParameter { itemFormat: 'datetime' default?: string[] } interface IdListParameter extends BaseListParameter { itemFormat: 'id' default?: string[] } interface EnumListParameter extends BaseListParameter { itemFormat: 'enum' itemEnumValues: EnumValue[] default?: EnumValue[] } interface ObjectListParameter extends BaseListParameter { itemFormat: 'object' itemParameters: Parameter[] } interface RecordListParameter extends BaseListParameter { itemFormat: 'record' } interface DiscriminatedListParameter extends BaseListParameter { itemFormat: 'discriminated_object' discriminator: string variants: Array<{ parameters: Parameter[] description: BaseParameter['description'] }> } interface BooleanParameter extends BaseParameter { format: 'boolean' jsonType: 'boolean' default?: boolean | null } interface ObjectParameter extends BaseParameter { format: 'object' jsonType: 'object' parameters: Parameter[] } interface DatetimeParameter extends BaseParameter { format: 'datetime' jsonType: 'string' default?: string | null } interface IdParameter extends BaseParameter { format: 'id' jsonType: 'string' default?: string | null } export type Parameter = | StringParameter | NumberParameter | EnumParameter | RecordParameter | ListParameter | BooleanParameter | ObjectParameter | DatetimeParameter | IdParameter export interface Request { methods: Method[] semanticMethod: Method preferredMethod: Method parameters: Parameter[] } export type Response = VoidResponse | ResourceResponse | ResourceListResponse interface BaseResponse { description: string } interface VoidResponse extends BaseResponse { responseType: 'void' } interface ResourceResponse extends BaseResponse { responseType: 'resource' responseKey: string resourceType: string actionAttemptType: string | null batchResourceTypes: Array<{ batchKey: string; resourceType: string }> | null } interface ResourceListResponse extends BaseResponse { responseType: 'resource_list' responseKey: string resourceType: string } interface BaseProperty { name: string description: string isDeprecated: boolean deprecationMessage: string isUndocumented: boolean undocumentedMessage: string isDraft: boolean draftMessage: string propertyGroupKey: string | null } export type Property = | StringProperty | NumberProperty | EnumProperty | RecordProperty | ListProperty | ObjectProperty | BooleanProperty | DatetimeProperty | IdProperty | BatchResourceProperty interface StringProperty extends BaseProperty { format: 'string' jsonType: 'string' } interface NumberProperty extends BaseProperty { format: 'number' jsonType: 'number' } export interface EnumProperty extends BaseProperty { format: 'enum' jsonType: 'string' values: EnumValue[] } type EnumValue = BaseProperty interface RecordProperty extends BaseProperty { format: 'record' jsonType: 'object' } interface BaseListProperty extends BaseProperty { format: 'list' jsonType: 'array' } interface StringListProperty extends BaseListProperty { itemFormat: 'string' } interface NumberListProperty extends BaseListProperty { itemFormat: 'number' } interface BooleanListProperty extends BaseListProperty { itemFormat: 'boolean' } interface DatetimeListProperty extends BaseListProperty { itemFormat: 'datetime' } interface IdListProperty extends BaseListProperty { itemFormat: 'id' } interface BatchResourceProperty extends BaseProperty { format: 'record' jsonType: 'object' resourceType: string } interface EnumListProperty extends BaseListProperty { itemFormat: 'enum' itemEnumValues: EnumValue[] } interface ObjectListProperty extends BaseListProperty { itemFormat: 'object' itemProperties: Property[] } interface RecordListProperty extends BaseListProperty { itemFormat: 'record' } export interface DiscriminatedListProperty extends BaseListProperty { itemFormat: 'discriminated_object' discriminator: string variantGroups: VariantGroup[] variants: Array<{ resourceType: string | null variantGroupKey: string | null properties: Property[] description: BaseProperty['description'] }> } type ListProperty = | StringListProperty | NumberListProperty | BooleanListProperty | DatetimeListProperty | IdListProperty | EnumListProperty | ObjectListProperty | RecordListProperty | DiscriminatedListProperty interface BooleanProperty extends BaseProperty { format: 'boolean' jsonType: 'boolean' } interface ObjectProperty extends BaseProperty { format: 'object' jsonType: 'object' properties: Property[] propertyGroups: PropertyGroup[] } interface DatetimeProperty extends BaseProperty { format: 'datetime' jsonType: 'string' } interface IdProperty extends BaseProperty { format: 'id' jsonType: 'string' } export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' interface Context extends Required<BlueprintOptions> { codeSampleDefinitions: CodeSampleDefinition[] resourceSampleDefinitions: ResourceSampleDefinition[] validActionAttemptTypes: string[] validResourceTypes: string[] schemas: Record<string, unknown> } 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 type TypesModuleInput = z.input<typeof TypesModuleSchema> export type TypesModule = z.output<typeof TypesModuleSchema> export interface BlueprintOptions { formatCode?: (content: string, syntax: SyntaxName) => Promise<string> } export const createBlueprint = async ( typesModule: TypesModuleInput, { formatCode = async (content) => content }: BlueprintOptions = {}, ): Promise<Blueprint> => { const { schemas, codeSampleDefinitions, resourceSampleDefinitions } = TypesModuleSchema.parse(typesModule) // TODO: Move openapi to TypesModuleSchema const openapi = typesModule.openapi as Openapi const validActionAttemptTypes = extractValidActionAttemptTypes( openapi.components.schemas, ) const validResourceTypes = extractValidResourceTypes( openapi.components.schemas, ) const context: 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: string): boolean => path === '/seam' || path.startsWith('/seam/') const assertSeamPathsAreUndocumented = ({ routes, namespaces, resources, events, actionAttempts, }: Pick< Blueprint, 'routes' | 'namespaces' | 'resources' | 'events' | 'actionAttempts' >): void => { 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, }: Pick<Blueprint, 'routes' | 'resources'>): void => { const undocumentedResourceTypes = new Set( resources.filter((r) => r.isUndocumented).map((r) => r.resourceType), ) const offenders: string[] = [] 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: Openapi['components']['schemas'], ): string[] => { const actionAttemptSchema = schemas['action_attempt'] if ( actionAttemptSchema == null || typeof actionAttemptSchema !== 'object' || !('oneOf' in actionAttemptSchema) || !Array.isArray(actionAttemptSchema.oneOf) ) { return [] } const processedActionAttemptTypes = new Set<string>() 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: Openapi['components']['schemas'], ): string[] => { return Object.keys(schemas) } const createRoutes = async ( paths: OpenapiPaths, context: Context, ): Promise<Route[]> => { const routeMap = new Map<string, Route>() 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: string, paths: OpenapiPaths): string | null => { // Hold namespace in array to allow nested namespaces // e.g. namespace for `/foo/bar/baz/get` = `/foo/bar` const namespace: string[] = [] 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: string | null, path: string, pathItem: OpenapiPathItem, context: Context, ): Promise<Route> => { 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): Route => ({ ...route, isDeprecated: route.endpoints.every((endpoint) => endpoint.isDeprecated), }) const addIsUndocumentedToRoute = (route: Route): Route => ({ ...route, isUndocumented: route.endpoints.every((endpoint) => endpoint.isUndocumented), }) const addIsDraftToRoute = (route: Route): Route => ({ ...route, isDraft: route.endpoints.every((endpoint) => endpoint.isDraft), }) const createNamespaces = (routes: Route[]): Namespace[] => { 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: string, pathItem: OpenapiPathItem, context: Context, ): Promise<Endpoint | null> => { const validMethods: Method[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] const validOperations = Object.entries(pathItem).filter( ([method, operation]) => validMethods.includes(method.toUpperCase() as Method) && typeof operation === 'object' && operation !== null, ) const supportedMethods = validOperations.map( ([method]) => method.toUpperCase() as Method, ) 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 as OpenapiOperation, path, context, ) } const createEndpointFromOperation = async ( methods: Method[], operation: OpenapiOperation, path: string, context: Context, ): Promise<Endpoint> => { 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 as OpenapiAuthMethod }, ) const endpointAuthMethods = [ ...new Set( operationAuthMethods .map(mapOpenapiToSeamAuthMethod) .filter( (authMethod): authMethod is SeamAuthMethod => authMethod != null, ), ), ] const workspaceScope = getWorkspaceScope(operationAuthMethods) const endpoint: Omit<Endpoint, 'codeSamples'> = { 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: OpenapiAuthMethod[], ): SeamWorkspaceScope => { const hasWorkspaceUnscoped = authMethods.some((method) => method.endsWith('_without_workspace'), ) const workspaceScopedAuthMethods: OpenapiAuthMethod[] = [ '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: Method[], operation: OpenapiOperation, path: string, ): Request => { 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: OpenapiOperation, path: string, ): Parameter[] => { // 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: Record<string, OpenapiSchema>, path: string, requiredParameters: string[] = [], ): Parameter[] => 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] as [string, OpenapiSchema] } return [name, flattenOpenapiSchema(property)] as [string, OpenapiSchema] }) .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: string, property: OpenapiSchema, path: string, requiredParameters: string[], ): Parameter => { const parsedProp = PropertySchema.parse(property, { path: [...path.split('/'), name], }) const baseParam: BaseParameter & { default?: any } = { 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: string | boolean) => { 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: BaseParameter, property: OpenapiSchema, path: string, ): Parameter => { function createListParameter<T extends BaseListParameter>( format: string, extraProps: Partial<T> = {}, ): T { return { ...baseParam, format: 'list' as const, jsonType: 'array' as const, itemFormat: format, ...extraProps, } as unknown as T } const fallbackListParameter = createListParameter<RecordListParameter>('record') if (property.items == null) { return fallbackListParameter } if ('oneOf' in property.items) { if ( !property.items.oneOf.every( (schema: OpenapiSchema) => 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<DiscriminatedListParameter>( 'discriminated_object', { discriminator: property.items.discriminator.propertyName, variants: property.items.oneOf.map((schema: OpenapiSchema) => ({ 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<StringListParameter>('string') case 'number': return createListParameter<NumberListParameter>('number') case 'boolean': return createListParameter<BooleanListParameter>('boolean') case 'datetime': return createListParameter<DatetimeListParameter>('datetime') case 'id': return createListParameter<IdListParameter>('id') case 'enum': return createListParameter<EnumListParameter>('enum', { itemEnumValues: itemParameter.values, }) case 'object': return createListParameter<ObjectListParameter>('object', { itemParameters: itemParameter.parameters, }) case 'record': return createListParameter<RecordListParameter>('record') default: return fallbackListParameter } } const createPagination = ( schema: Openapi['components']['schemas'][number] | undefined, schemas: Openapi['components']['schemas'], ): Pagination | null => { if (schema == null) return null return { responseKey: paginationResponseKey, description: normalizeDescription(schema.description ?? ''), properties: createProperties( schema.properties ?? {}, [paginationResponseKey], [], schemas, ), } } export const createResources = async ( schemas: Openapi['components']['schemas'], routes: Route[], context: Context, ): Promise<Resource[]> => { const resources: Resource[] = [] 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: OpenapiSchema = { '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: string, schema: OpenapiSchema, schemas: Openapi['components']['schemas'], routes: Route[], context: Context, ): Promise<Resource> => { const routePath = validateRoutePath( schemaName, schema['x-route-path'], routes, ) const propertyGroups = getPropertyGroups(schema) const resourceType = schemaName const resource: Omit<Resource, 'resourceSamples'> = { 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: string, routePath: string | undefined, routes: Route[], ): string => { 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: OpenapiSchema): PropertyGroup[] => { const rawPropertyGroups = schema['x-property-groups'] ?? {} const propertyGroups: Record<string, PropertyGroup> = {} for (const [key, group] of Object.entries(rawPropertyGroups)) { propertyGroups[key] = { name: group.name, propertyGroupKey: key, } } return Object.values(propertyGroups) } const getVariantGroups = (schema: OpenapiSchema): VariantGroup[] => { const rawVariantGroups = schema['x-variant-groups'] ?? {} const variantGroups: Record<string, VariantGroup> = {} for (const [key, group] of Object.entries(rawVariantGroups)) { variantGroups[key] = { name: group.name, variantGroupKey: key, } } return Object.values(variantGroups) } const createResponse = ( operation: OpenapiOperation, path: string, context: Context, ): Response & { hasPagination: boolean } => { 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: string | null, responseKey: string, path: string, resourceType: string, validActionAttemptTypes: string[], ): string | null => { 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: Record<string, OpenapiSchema>, parentPaths: string[], propertyGroups: PropertyGroup[], schemas: Openapi['components']['schemas'], ): Property[] => 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] as [string, OpenapiSchema] } return [name, flattenOpenapiSchema(property)] as [string, OpenapiSchema] }) .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: string, prop: OpenapiSchema, parentPaths: string[], propertyGroups: PropertyGroup[], schemas: Openapi['components']['schemas'], ): Property => { 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'] as string 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: 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: string | boolean) => { 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: string | null, propertyName: string, parentPaths: string[], groups: PropertyGroup[] | VariantGroup[], ): void => { 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: BaseProperty, prop: OpenapiSchema, parentPaths: string[], schemas: Openapi['components']['schemas'], ): Property => { function createListProperty<T extends ListProperty>( format: string, extraProps: Partial<T> = {}, ): T { return { ...baseProperty, format: 'list' as const, jsonType: 'array' as const, itemFormat: format, ...extraProps, } as unknown as T } const fallbackListProperty = createListProperty<RecordListProperty>('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<DiscriminatedListProperty>( '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<StringListProperty>('string') case 'number': return createListProperty<NumberListProperty>('number') case 'boolean': return createListProperty<BooleanListProperty>('boolean') case 'datetime': return createListProperty<DatetimeListProperty>('datetime') case 'id': return createListProperty<IdListProperty>('id') case 'enum': return createListProperty<EnumListProperty>('enum', { itemEnumValues: itemProperty.values, }) case 'object': if (!('properties' in itemProperty)) { throw new Error( `Missing properties for object in ${parentPaths.join('.')}`, ) } return createListProperty<ObjectListProperty>('object', { itemProperties: itemProperty.properties, }) case 'record': return createListProperty<RecordListProperty>('record') default: return fallbackListProperty } } const parsePropertySchema = ( name: string, prop: OpenapiSchema, parentPaths: string[], schemas: Openapi['components']['schemas'], ): z.infer<typeof PropertySchema> => { if (parentPaths.includes('batch')) { return parseBatchPropertySch