Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions packages/openapi-typescript/src/lib/ts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ function addIndexedAccess(node: ts.TypeReferenceNode | ts.IndexedAccessTypeNode,
* must check the parameter definition to determine the how to index into
* the openapi-typescript type.
**/
export function oapiRef(path: string, resolved?: OapiRefResolved): ts.TypeNode {
export function oapiRef(path: string, resolved?: OapiRefResolved, deep = false): ts.TypeNode {
const { pointer } = parseRef(path);
if (pointer.length === 0) {
throw new Error(`Error parsing $ref: ${path}. Is this a valid $ref?`);
Expand All @@ -179,7 +179,9 @@ export function oapiRef(path: string, resolved?: OapiRefResolved): ts.TypeNode {
const restSegments = pointer.slice(3);

const leadingType = addIndexedAccess(
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier(String(initialSegment))),
ts.factory.createTypeReferenceNode(
ts.factory.createIdentifier(deep ? `FlattenedDeepRequired<${String(initialSegment)}>` : String(initialSegment)),
),
...leadingSegments,
);

Expand Down Expand Up @@ -305,6 +307,18 @@ export function tsArrayLiteralExpression(
let variableName = sanitizeMemberName(name);
variableName = `${variableName[0].toLowerCase()}${variableName.substring(1)}`;

if (
options?.injectFooter &&
!options.injectFooter.some(
(node) => ts.isTypeAliasDeclaration(node) && node?.name?.escapedText === "FlattenedDeepRequired",
)
) {
const helper = stringToAST(
"type FlattenedDeepRequired<T> = { [K in keyof T]-?: FlattenedDeepRequired<T[K] extends unknown[] | undefined | null ? Extract<T[K], unknown[]>[number] : T[K]>; };",
)[0] as any;
options.injectFooter.push(helper);
}

const arrayType = options?.readonly
? tsReadonlyArray(elementType, options.injectFooter)
: ts.factory.createArrayTypeNode(elementType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default function transformRequestBodyObject(
): ts.TypeNode {
const type: ts.TypeElement[] = [];
for (const [contentType, mediaTypeObject] of getEntries(requestBodyObject.content ?? {}, options.ctx)) {
const nextPath = createRef([options.path, contentType]);
const nextPath = createRef([options.path, "content", contentType]);
const mediaType =
"$ref" in mediaTypeObject
? transformSchemaObject(mediaTypeObject, {
Expand Down
50 changes: 42 additions & 8 deletions packages/openapi-typescript/src/transform/schema-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ import type { ReferenceObject, SchemaObject, TransformNodeOptions } from "../typ
export default function transformSchemaObject(
schemaObject: SchemaObject | ReferenceObject,
options: TransformNodeOptions,
fromAdditionalProperties = false,
): ts.TypeNode {
const type = transformSchemaObjectWithComposition(schemaObject, options);
const type = transformSchemaObjectWithComposition(schemaObject, options, fromAdditionalProperties);
if (typeof options.ctx.postTransform === "function") {
const postTransformResult = options.ctx.postTransform(type, options);
if (postTransformResult) {
Expand All @@ -51,6 +52,7 @@ export default function transformSchemaObject(
export function transformSchemaObjectWithComposition(
schemaObject: SchemaObject | ReferenceObject,
options: TransformNodeOptions,
fromAdditionalProperties = false,
): ts.TypeNode {
/**
* Unexpected types & edge cases
Expand Down Expand Up @@ -138,14 +140,39 @@ export function transformSchemaObjectWithComposition(

// hoist array with valid enum values to top level if string/number enum and option is enabled
if (options.ctx.enumValues && schemaObject.enum.every((v) => typeof v === "string" || typeof v === "number")) {
let enumValuesVariableName = parseRef(options.path ?? "").pointer.join("/");
const parsed = parseRef(options.path ?? "");
let enumValuesVariableName = parsed.pointer.join("/");
// allow #/components/schemas to have simpler names
enumValuesVariableName = enumValuesVariableName.replace("components/schemas", "");
enumValuesVariableName = `${enumValuesVariableName}Values`;

// build a ref path for the type that ignores union indices (anyOf/oneOf) so
// type references remain stable even when names include union positions
const cleanedPointer: string[] = [];
for (let i = 0; i < parsed.pointer.length; i++) {
// Example: #/paths/analytics/data/get/responses/400/content/application/json/anyOf/0/message
const segment = parsed.pointer[i];
if ((segment === "anyOf" || segment === "oneOf") && i < parsed.pointer.length - 1) {
const next = parsed.pointer[i + 1];
if (/^\d+$/.test(next)) {
// If we encounter something like "anyOf/0", we want to skip that part of the path
i++;
continue;
}
}
cleanedPointer.push(segment);
}
const cleanedRefPath = createRef(cleanedPointer);

const enumValuesArray = tsArrayLiteralExpression(
enumValuesVariableName,
oapiRef(options.path ?? ""),
// If fromAdditionalProperties is true we are dealing with a record type and we should append [string] to the generated type
fromAdditionalProperties
? ts.factory.createIndexedAccessTypeNode(
oapiRef(cleanedRefPath, undefined, true),
ts.factory.createTypeReferenceNode(ts.factory.createIdentifier("string")),
)
: oapiRef(cleanedRefPath, undefined, true),
schemaObject.enum as (string | number)[],
{
export: true,
Expand All @@ -165,10 +192,16 @@ export function transformSchemaObjectWithComposition(
*/

/** Collect oneOf/anyOf */
function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[]) {
function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[], unionKey: "anyOf" | "oneOf") {
const output: ts.TypeNode[] = [];
for (const item of items) {
output.push(transformSchemaObject(item, options));
for (const [index, item] of items.entries()) {
output.push(
transformSchemaObject(item, {
...options,
// include index in path so generated names from nested enums/enumValues are unique
path: createRef([options.path, unionKey, String(index)]),
}),
);
}

return output;
Expand Down Expand Up @@ -233,7 +266,7 @@ export function transformSchemaObjectWithComposition(
}
// anyOf: union
// (note: this may seem counterintuitive, but as TypeScript’s unions are not true XORs, they mimic behavior closer to anyOf than oneOf)
const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? []);
const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? [], "anyOf");
if (anyOfType.length) {
finalType = tsUnion([...(finalType ? [finalType] : []), ...anyOfType]);
}
Expand All @@ -244,6 +277,7 @@ export function transformSchemaObjectWithComposition(
schemaObject.type === "object" &&
(schemaObject.enum as (SchemaObject | ReferenceObject)[])) ||
[],
"oneOf",
);
if (oneOfType.length) {
// note: oneOf is the only type that may include primitives
Expand Down Expand Up @@ -547,7 +581,7 @@ function transformSchemaObjectCore(schemaObject: SchemaObject, options: Transfor
const hasExplicitAdditionalProperties =
typeof schemaObject.additionalProperties === "object" && Object.keys(schemaObject.additionalProperties).length;
const addlType = hasExplicitAdditionalProperties
? transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options)
? transformSchemaObject(schemaObject.additionalProperties as SchemaObject, options, true)
: UNKNOWN;
return tsIntersection([
...(coreObjectType.length ? [ts.factory.createTypeLiteralNode(coreObjectType)] : []),
Expand Down
Loading
Loading