@seamapi/blueprint
Version:
Build tools for the Seam API using this blueprint.
1,095 lines • 46.3 kB
JavaScript
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