Skip to content

Commit

Permalink
feat(fhir-ts-codegen): support recursive types
Browse files Browse the repository at this point in the history
Refactored to support generating boilerplate io-ts code for recursive
types, including in backbone elements. Also includes type level
comments.
  • Loading branch information
tangdrew committed Feb 17, 2019
1 parent cc39576 commit dad837b
Show file tree
Hide file tree
Showing 2 changed files with 208 additions and 72 deletions.
215 changes: 171 additions & 44 deletions packages/fhir-ts-codegen/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,46 +5,76 @@
import { writeFile } from "fs";
import { promisify } from "util";

import { ElementDefinition, FHIRPrimitives } from "./conformance";
import {
ElementDefinition,
ElementGroup,
FHIRPrimitives,
FHIRPrimitivesTypes
} from "./conformance";

export const writeFileAsync = promisify(writeFile);

/**
* Groups list of ElementDefinitions by BackboneElements
* Groups list of ElementDefinitions into related elements
* E.g. the root resource and the embedded backbone elements
*/
export const getBackboneElementDefinitions = (
export const getElementGroups = (
resourceName: string,
elementDefinitions: ElementDefinition[]
): { [key: string]: ElementDefinition[] } => {
const backboneElements = elementDefinitions
.filter(isBackboneDefinition)
): ElementGroup[] => {
const groupNames = elementDefinitions
.filter(
el =>
isBackboneDefinition(el) ||
isElementDefinition(el) ||
isResourceDefinition(resourceName, el)
)
.map(e => pathToPascalCase(e.path));

return elementDefinitions.reduce<{ [key: string]: ElementDefinition[] }>(
const groups = elementDefinitions.reduce<{ [key: string]: ElementGroup }>(
(accum, curr) => {
const { path } = curr;
const parentName = pathToPascalCase(
path
.split(".")
.slice(0, -1)
.join(".")
);

const isBackboneChild = backboneElements.includes(parentName);

if (isBackboneChild) {
return {
...accum,
[parentName]: [...(accum[parentName] || []), curr]
};
}
const parentName = isResourceDefinition(resourceName, curr)
? ""
: pathToPascalCase(
path
.split(".")
.slice(0, -1)
.join(".")
);

const name = pathToPascalCase(path);
const isGroupRoot = groupNames.includes(name);
const group = accum[name] || {};
const isGroupChild = groupNames.includes(parentName);
const parentGroup = accum[parentName] || {};

return {
...accum,
[resourceName]: [...(accum[resourceName] || []), curr]
...(isGroupRoot && {
[name]: {
...group,
comment: curr.short || "",
definitions: [],
name
}
}),
...(isGroupChild && {
[parentName]: {
...parentGroup,
definitions: [...(parentGroup.definitions || []), curr]
}
})
};
},
{}
);

// Sorts list reverse alphabetically so deeper nested groups are defined first
return Object.keys(groups)
.sort()
.reverse()
.map(key => groups[key]);
};

/**
Expand All @@ -60,8 +90,10 @@ export const getImports = (
const nonPrimitiveTypes = (type || [])
.filter(
({ code }) =>
code &&
!Object.values(FHIRPrimitives).includes(code) &&
code !== "BackboneElement"
code !== "BackboneElement" &&
code !== "Element"
)
.map(({ code }) => code);
return [...accum, ...nonPrimitiveTypes];
Expand All @@ -71,62 +103,157 @@ export const getImports = (
};

/**
* Given an array of types, returns io-ts type declaration string
* Wrap io-ts RuntimeType declaration in recursive type boilerplate
* to provide TypeScript static type hint
*/
export const typeDeclaration = (elementDefinition: ElementDefinition) => {
export const wrapRecursive = (name: string, runType: string) => {
return `export const ${name}: t.RecursiveType<t.Type<I${name}>> = t.recursion('${name}', () =>
${runType}
)`;
};

interface TypeInfo {
display: string[];
array: boolean;
}

/**
* Parses ElementDefinition into info needed to display types
* Encodes special cases like content references
*/
export const parseType = (elementDefinition: ElementDefinition): TypeInfo => {
const { contentReference, path, type } = elementDefinition;
const array = isArray(elementDefinition);

// TODO: Why is Element type only an extension?
if (path === "Element.id" || path === "Extension.url") {
return "primitives.R4.string";
return {
array,
display: ["string"]
};
}

// If contentReference, type is reference name
if (!!contentReference) {
return pathToPascalCase(contentReference.slice(1));
return {
array,
display: [pathToPascalCase(contentReference.slice(1))]
};
}

if (isBackboneDefinition(elementDefinition)) {
return pathToPascalCase(path);
// If backbone or element definition, type is element name
if (
isBackboneDefinition(elementDefinition) ||
isElementDefinition(elementDefinition)
) {
return {
array,
display: [pathToPascalCase(path)]
};
}

const declarations = (type || []).map(({ code }) =>
Object.values(FHIRPrimitives).includes(code)
? `primitives.R4.${code}`
: code
return {
array,
display: (type || []).map(({ code }) => code)
};
};

/**
* Given an array of types, returns io-ts type declaration string
*/
export const typeDeclaration = (elementDefinition: ElementDefinition) => {
const { array, display } = parseType(elementDefinition);

const declarations = display.map(type =>
Object.values(FHIRPrimitives).includes(type)
? `primitives.R4.${type}`
: type
);
if (declarations.length === 1) {
return declarations[0];
}
return `t.union([${declarations.join(", ")}])`;
const declaration =
declarations.length === 1
? declarations[0]
: `t.union([${declarations.join(", ")}])`;
return array ? `t.array(${declaration})` : declaration;
};

/**
* Generates TypeScript interface from list of ElementDefinitions
*/
export const generateInterface = ({ name, definitions }: ElementGroup) => {
return `interface I${name} {
${definitions
.map(element => {
const { min, path } = element;
const propertyName = elementName(element);
const { array, display } = parseType(element);
const isRequired = min! > 0;
const typeName = display
.map(type =>
Object.values(FHIRPrimitives).includes(type)
? `t.TypeOf<primitives.R4.${FHIRPrimitivesTypes[type]}>`
: type
)
.join(" | ");
if (!typeName) {
throw new Error(`Expected a type for element ${path}.`);
}
return `${propertyName}${isRequired ? "" : "?"}: ${typeName}${
array ? "[]" : ""
};`;
})
.join("\n")}
}`;
};

export const elementName = (elementDefinition: ElementDefinition) => {
const { path, type } = elementDefinition;
const { path } = elementDefinition;
if (path.split(".").length === 1) {
return "";
}
const [elName] = path.split(".").slice(-1);
return isChoiceType(elementDefinition)
? stringsToCamelCase([
elName.substring(0, elName.length - 3),
...type!.map(({ code }) => code)
])
? stringsToCamelCase([elName.substring(0, elName.length - 3)])
: elName;
};

/**
* Determines if ElementDefinition is root declaration of BackboneElement
* Determines if ElementDefinition is root declaration of Resource
*/
export const isResourceDefinition = (
resourceName: string,
e: ElementDefinition
) => e.path === resourceName;

/**
* Determines if ElementDefinition is root declaration of BackboneElement on a Resource
*/
export const isBackboneDefinition = (e: ElementDefinition) =>
(e.type || []).some(({ code }) => code === "BackboneElement");

/**
* Determines if ElementDefinition is root declaration of Element on a complex type
*/
export const isElementDefinition = (e: ElementDefinition) =>
(e.type || []).some(({ code }) => code === "Element");

/**
* Whether an Element Definition is defining a Choice Type
* https://www.hl7.org/fhir/formats.html#choice
*/
export const isChoiceType = ({ path }: ElementDefinition) =>
!!path && path.substr(-3) === "[x]";

/**
* Whether an Element Definition is a list
*/
const isArray = ({ max }: ElementDefinition) =>
!!(
max &&
(max === "*" || (!isNaN(parseInt(max, 10)) && parseInt(max, 10) > 1))
);

/**
* Formats ElementDefinition path to pascal case
*/
Expand Down
65 changes: 37 additions & 28 deletions packages/fhir-ts-codegen/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,19 @@ import { readFileSync } from "fs";
import * as glob from "glob";
import { format } from "prettier";

import { ElementDefinition, StructureDefinition } from "./conformance";
import {
ElementDefinition,
ElementGroup,
StructureDefinition
} from "./conformance";
import {
elementName,
getBackboneElementDefinitions,
generateInterface,
getElementGroups,
getImports,
parseType,
typeDeclaration,
wrapRecursive,
writeFileAsync
} from "./helpers";

Expand Down Expand Up @@ -78,34 +85,25 @@ export class Generator {
private renderModule = (structureDefinition: StructureDefinition): File => {
const { name, snapshot, type } = structureDefinition;
// TODO: Support from Differential
const elementDefinitions = (snapshot!.element || []).filter(
element => element.path !== type
); // Filter out root ElementDefinition
const elementDefinitions = snapshot!.element || [];

const backboneElements = getBackboneElementDefinitions(
name,
elementDefinitions
);
const elementGroups = getElementGroups(name, elementDefinitions);

const imports = this.configuration.singleFile
? ""
: `${getImports(elementDefinitions)
: `${getImports(
elementDefinitions.filter(element => element.path !== type) // Filter out root ElementDefinition
)
.filter(i => i !== type)
.map(i => `import {${i}} from "./${i}"`)
.join("\n")}`;

const typeDeclarations = Object.keys(backboneElements)
.sort()
.reverse()
.map(elName => {
const elements = backboneElements[elName];
return this.generateType(elName, elements);
})
.join("\n\n");
const typeDeclarations = elementGroups.map(this.generateType).join("\n\n");

const code = format(
`
/**
* Module Comment
* ${name} Module
*/
import * as primitives from "@tangdrew/primitives";
import * as t from "io-ts";
Expand All @@ -126,23 +124,34 @@ export class Generator {
/**
* Generates io-ts type code from list of ElementDefinitions
*/
private generateType = (
name: string,
elementDefinitions: ElementDefinition[]
): string => {
const [required, optional] = this.categorizeElementDefinitions(
elementDefinitions
);
private generateType = (elementGroup: ElementGroup): string => {
const { comment, definitions, name } = elementGroup;
const [required, optional] = this.categorizeElementDefinitions(definitions);
const requiredProperties = this.getProperties(required);
const optionalProperties = this.getProperties(optional);
return `export const ${name} = t.intersection([
const isRecursive = definitions.some(definition => {
const { display } = parseType(definition);
return display.some(type => type === name);
});

const runType = `t.intersection([
t.type({
${requiredProperties.join(",\n")}
}),
t.partial({
${optionalProperties.join(",\n")}
})
], "${name}");
], "${name}")`;

return `/**
* ${comment}
*/
${isRecursive ? generateInterface(elementGroup) : ""}
${
isRecursive
? wrapRecursive(name, runType)
: `export const ${name} = ${runType}`
}
export interface ${name} extends t.TypeOf<typeof ${name}> {}`;
};

Expand Down

0 comments on commit dad837b

Please sign in to comment.