@seamapi/blueprint
Version:
Build tools for the Seam API using this blueprint.
1,890 lines (1,636 loc) • 56.8 kB
text/typescript
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