/** * CloudFormation Registry JSON Schema parser. * * Parses each CFNSchema into typed structures suitable for code generation: * resources with properties and attributes, property types from definitions, * and enum types from string enum definitions. */ import type { CFNSchema, SchemaProperty, SchemaDefinition } from "./fetch"; import { resolvePropertyType as coreResolvePropertyType, extractConstraints as coreExtractConstraints, constraintsIsEmpty as coreConstraintsIsEmpty, isEnumDefinition as coreIsEnumDefinition, type PropertyConstraints, type JsonSchemaDocument, type JsonSchemaProperty, type JsonSchemaDefinition, } from "@intentius/chant/codegen/json-schema"; import { boundPropertyTypes } from "@intentius/chant/codegen/bound-property-types"; export type { PropertyConstraints } from "@intentius/chant/codegen/json-schema"; /** * Maximum nesting depth of generated property-type interfaces. Bounds the * shipped `.d.ts` size: property types reachable from a resource's top-level * properties within this depth stay typed; deeper ones are loosened to * `Record`. Set high enough that the shapes composites consume * (which reach a few levels into CFN config) remain typed. (#440) */ const MAX_PROPERTY_TYPE_DEPTH = 3; export interface ParsedProperty { name: string; tsType: string; required: boolean; description?: string; enum?: string[]; constraints: PropertyConstraints; } export interface ParsedAttribute { name: string; tsType: string; } export interface ParsedPropertyType { name: string; specType: string; properties: ParsedProperty[]; } export interface ParsedEnum { name: string; values: string[]; } export interface ParsedResource { typeName: string; properties: ParsedProperty[]; attributes: ParsedAttribute[]; createOnly: string[]; writeOnly: string[]; primaryIdentifier: string[]; deprecatedProperties: string[]; conditionalCreateOnly: string[]; replacementStrategy?: "delete_then_create" | "create_then_delete"; tagging?: { taggable: boolean; tagOnCreate: boolean; tagUpdatable: boolean }; } export interface SchemaParseResult { resource: ParsedResource; propertyTypes: ParsedPropertyType[]; enums: ParsedEnum[]; } /** * Parse a CloudFormation Registry JSON Schema into typed structures. */ export function parseCFNSchema(data: string | Buffer): SchemaParseResult { const schema: CFNSchema = JSON.parse(typeof data === "string" ? data : data.toString("utf-8")); const requiredSet = new Set(schema.required ?? []); const shortName = cfnShortName(schema.typeName); // Parse top-level properties const props: ParsedProperty[] = []; if (schema.properties) { for (const [name, prop] of Object.entries(schema.properties)) { const tsType = resolvePropertyType(prop, schema); props.push({ name, tsType, required: requiredSet.has(name), description: prop.description, enum: prop.enum, constraints: extractConstraints(prop), }); } } // Parse readOnlyProperties as attributes // Deduplicate — some upstream schemas (e.g. aws-s3files-filesystem) list the same // property twice in readOnlyProperties, which would produce duplicate class members. const attrs: ParsedAttribute[] = []; const seenAttrs = new Set(); for (const path of schema.readOnlyProperties ?? []) { const attrName = stripPointerPath(path); // Flatten nested paths: "Endpoint/Address" → attr name "Endpoint.Address" const cfnAttr = attrName.replace(/\//g, "."); if (seenAttrs.has(cfnAttr)) continue; seenAttrs.add(cfnAttr); let tsType = "string"; // For top-level attrs, look up type from properties if (!cfnAttr.includes(".") && schema.properties?.[cfnAttr]) { tsType = resolvePropertyType(schema.properties[cfnAttr], schema); } // For nested attrs, type is always string (CF GetAtt returns strings for leaf values) attrs.push({ name: cfnAttr, tsType }); } // Parse definitions into property types and enums const propertyTypes: ParsedPropertyType[] = []; const enums: ParsedEnum[] = []; if (schema.definitions) { for (const [defName, def] of Object.entries(schema.definitions)) { if (isEnumDefinition(def)) { enums.push({ name: `${shortName}_${defName}`, values: def.enum!, }); continue; } if (def.properties) { const defRequired = new Set(def.required ?? []); const defProps: ParsedProperty[] = []; for (const [propName, prop] of Object.entries(def.properties)) { const tsType = resolvePropertyType(prop, schema); defProps.push({ name: propName, tsType, required: defRequired.has(propName), description: prop.description, enum: prop.enum, constraints: extractConstraints(prop), }); } propertyTypes.push({ name: `${shortName}_${defName}`, specType: defName, properties: defProps, }); } } } // --- Deprecated properties: explicit + description-mined --- const deprecatedSet = new Set( stripPointerPaths(schema.deprecatedProperties ?? []), ); const DEPRECATION_RE = /\bdeprecated\b|\blegacy\b|no longer (available|recommended|used|supported)|is not recommended|has been discontinued/i; // Mine top-level property descriptions if (schema.properties) { for (const [name, prop] of Object.entries(schema.properties)) { if (prop.description && DEPRECATION_RE.test(prop.description)) { deprecatedSet.add(name); } } } // --- Tagging --- let tagging: ParsedResource["tagging"]; if (schema.tagging && schema.tagging.taggable) { tagging = { taggable: true, tagOnCreate: schema.tagging.tagOnCreate ?? false, tagUpdatable: schema.tagging.tagUpdatable ?? false, }; } // --- Replacement strategy --- let replacementStrategy: ParsedResource["replacementStrategy"]; if (schema.replacementStrategy === "delete_then_create" || schema.replacementStrategy === "create_then_delete") { replacementStrategy = schema.replacementStrategy; } // Bound the emitted property types to keep the shipped declaration small while // preserving the shapes composites and shallow authoring rely on (#440). const boundedPropertyTypes = boundPropertyTypes( shortName, props, propertyTypes, new Set(enums.map((e) => e.name)), { maxDepth: MAX_PROPERTY_TYPE_DEPTH }, ); return { resource: { typeName: schema.typeName, properties: props, attributes: attrs, createOnly: stripPointerPaths(schema.createOnlyProperties ?? []), writeOnly: stripPointerPaths(schema.writeOnlyProperties ?? []), primaryIdentifier: stripPointerPaths(schema.primaryIdentifier ?? []), deprecatedProperties: [...deprecatedSet], conditionalCreateOnly: stripPointerPaths(schema.conditionalCreateOnlyProperties ?? []), ...(replacementStrategy && { replacementStrategy }), ...(tagging && { tagging }), }, propertyTypes: boundedPropertyTypes, enums, }; } // --- Type resolution (delegated to core) --- function resolvePropertyType(prop: SchemaProperty | undefined, schema: CFNSchema): string { const shortName = cfnShortName(schema.typeName); return coreResolvePropertyType( prop as JsonSchemaProperty | undefined, schema as unknown as JsonSchemaDocument, (defName) => `${shortName}_${defName}`, ); } function extractConstraints(prop: SchemaProperty): PropertyConstraints { return coreExtractConstraints(prop as JsonSchemaProperty); } export const constraintsIsEmpty = coreConstraintsIsEmpty; function isEnumDefinition(def: SchemaDefinition): boolean { return coreIsEnumDefinition(def as JsonSchemaDefinition); } /** * Extract short resource name: "AWS::S3::Bucket" → "Bucket" */ export function cfnShortName(typeName: string): string { const parts = typeName.split("::"); return parts.length >= 3 ? parts[2] : typeName; } /** * Extract service name: "AWS::S3::Bucket" → "S3" */ export function cfnServiceName(typeName: string): string { const parts = typeName.split("::"); return parts.length >= 2 ? parts[1] : typeName; } /** * Strip JSON pointer path prefix: "/properties/BucketName" → "BucketName" */ function stripPointerPath(path: string): string { const prefix = "/properties/"; return path.startsWith(prefix) ? path.slice(prefix.length) : path; } function stripPointerPaths(paths: string[]): string[] { if (paths.length === 0) return []; return paths.map(stripPointerPath); }