Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(type-safe-api): fix model serialisation for nested collections in typescript #882

Merged
merged 1 commit into from
Nov 10, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import _orderBy from "lodash/orderBy";
import _uniq from "lodash/uniq";
import _uniqBy from "lodash/uniqBy";
import _isEqual from "lodash/isEqual";
import _cloneDeepWith from "lodash/cloneDeepWith"
import { OpenAPIV3 } from "openapi-types";
import * as parseOpenapi from "parse-openapi";
import { getOperationResponses } from "parse-openapi/dist/parser/getOperationResponses";
Expand Down Expand Up @@ -302,6 +303,7 @@ const splitAndWriteFiles = (renderedFileContents: string[], outputPath: string)

// Model types which indicate it is composed (ie inherits/mixin's another schema)
const COMPOSED_SCHEMA_TYPES = new Set(["one-of", "any-of", "all-of"]);
const COLLECTION_TYPES = new Set(["array", "dictionary"]);
const PRIMITIVE_TYPES = new Set(["string", "integer", "number", "boolean", "null", "any", "binary", "void"]);

/**
Expand Down Expand Up @@ -496,7 +498,7 @@ const mutateModelWithAdditionalTypes = (model: parseOpenapi.Model) => {
(model as any).javaType = toJavaType(model);
(model as any).pythonName = toPythonName('property', model.name);
(model as any).pythonType = toPythonType(model);
(model as any).isPrimitive = PRIMITIVE_TYPES.has(model.type);
(model as any).isPrimitive = PRIMITIVE_TYPES.has(model.type) && !COMPOSED_SCHEMA_TYPES.has(model.export) && !COLLECTION_TYPES.has(model.export);
};

interface MockDataContext {
Expand Down Expand Up @@ -615,13 +617,17 @@ const _ensureModelLinks = (spec: OpenAPIV3.Document, modelsByName: {[name: strin
if (modelsByName[name] && !model.link) {
model.link = modelsByName[name];
}
} else if (model.link && typeof schema.additionalProperties !== 'boolean') {
_ensureModelLinks(spec, modelsByName, model.link, schema.additionalProperties, visited);
}
} else if (model.export === "array" && 'items' in schema && schema.items) {
if (isRef(schema.items)) {
const name = splitRef(schema.items.$ref)[2];
if (modelsByName[name] && !model.link) {
model.link = modelsByName[name];
}
} else if (model.link) {
_ensureModelLinks(spec, modelsByName, model.link, schema.items, visited);
}
}

Expand Down Expand Up @@ -713,13 +719,12 @@ const buildData = async (inSpec: OpenAPIV3.Document, metadata: any) => {
// In order for the new generator not to be breaking, we apply the same logic here, however this can be removed
// in future since we have control to avoid the duplicate handlers while allowing an operation to be part of
// multiple "services".
let spec = JSON.parse(JSON.stringify(inSpec, (key, value) => {
let spec = _cloneDeepWith(inSpec, (value, key) => {
// Keep only the first tag where we find a tag
if (key === "tags" && value && value.length > 0 && typeof value[0] === "string") {
return [value[0]];
}
return value;
})) as OpenAPIV3.Document;
}) as OpenAPIV3.Document;

// Ensure spec has schemas set
if (!spec?.components?.schemas) {
Expand Down Expand Up @@ -775,16 +780,15 @@ const buildData = async (inSpec: OpenAPIV3.Document, metadata: any) => {

// "Inline" any refs to non objects/enums
const inlinedRefs: Set<string> = new Set();
spec = JSON.parse(JSON.stringify(spec, (k, v) => {
spec = _cloneDeepWith(spec, (v) => {
if (v && typeof v === "object" && v.$ref) {
const resolved = resolveRef(spec, v.$ref);
if (resolved && resolved.type && resolved.type !== "object" && !(resolved.type === "string" && resolved.enum)) {
inlinedRefs.add(v.$ref);
return resolved;
}
}
return v;
}));
});

// Delete the non object schemas that were inlined
[...inlinedRefs].forEach(ref => {
Expand All @@ -808,7 +812,7 @@ const buildData = async (inSpec: OpenAPIV3.Document, metadata: any) => {
faker.setDefaultRefDate(new Date("2021-06-10"));
const mockDataContext: MockDataContext = {
faker,
dereferencedSpec: await SwaggerParser.dereference(structuredClone(spec), { dereference: { circular: 'ignore' }}) as OpenAPIV3.Document,
dereferencedSpec: await SwaggerParser.dereference(structuredClone(spec), { dereference: { circular: 'ignore' } }) as OpenAPIV3.Document,
};

// Augment operations with additional data
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ import {
} from './<%= importName %>';
<%_ }); _%>
<%_ const isComposite = model.export === "one-of" || model.export === "any-of" || model.export === "all-of"; _%>
<%_
// Nested arrays of primitives (besides dates) don't need to have .map(...) called to convert them as the base case will be a noop
// eg. an array of arrays of strings doesn't need to be rendered as `value.map(item0 => item0.map(item1 => item1))`
const canShortCircuitConversion = (property) => {
if (["array", "dictionary"].includes(property.export)) {
return canShortCircuitConversion(property.link);
}
return property.isPrimitive && !["date", "date-time"].includes(property.format);
};
_%>

<%_ if (model.export === "enum") { _%>
/**
Expand Down Expand Up @@ -126,19 +136,45 @@ export function <%= model.name %>FromJSONTyped(json: any, ignoreDiscriminator: b
<%_ } else { _%>
return {

<%_
// Renders the appropriate nested function for .map() or mapValues() for arrays and dictionaries for the given type
const renderNestedFromJsonValue = (type, depth = 0) => {
const itemIdentifier = `item${depth}`;
if (type.isPrimitive) {
return `(${itemIdentifier}) => ${["date", "date-time"].includes(type.format) ? `new Date(${itemIdentifier})` : itemIdentifier}`;
} else if (type.export === "array") {
return `(${itemIdentifier}) => ${itemIdentifier}.map(${renderNestedFromJsonValue(type.link, depth + 1)})`;
} else if (type.export === "dictionary") {
return `(${itemIdentifier}) => mapValues(${itemIdentifier}, ${renderNestedFromJsonValue(type.link, depth + 1)})`;
}
return `${type.name || type.type}FromJSON`;
};
// Renders the code to transform a property of the model from its json representation into the model types
const renderFromJsonValue = (property) => {
const value = `json['${property.name}']`;
let rendered = '';
if (canShortCircuitConversion(property)) {
rendered = value;
} else if (property.isPrimitive) {
rendered = ["date", "date-time"].includes(property.format) ? `(new Date(${value}))` : value;
} else if (property.export === "array") {
rendered = `((${value} as Array<any>).map(${renderNestedFromJsonValue(property.link)}))`;
rendered = property.uniqueItems ? `new Set(${rendered})` : rendered;
} else if (property.export === "dictionary") {
rendered = `(mapValues(${value}, ${renderNestedFromJsonValue(property.link)}))`;
} else {
rendered = `${property.type}FromJSON(${value})`;
}
rendered = property.isNullable ? `${value} === null ? null : ${rendered}` : rendered;
rendered = !property.isRequired ? `!exists(json, '${property.name}') ? undefined : ${rendered}` : rendered;
return rendered;
};
_%>
<%_ if (model.export === "dictionary") { _%>
...json,
<%_ } _%>
<%_ model.properties.forEach((property) => { _%>
<%_ if (property.isPrimitive) { _%>
'<%= property.typescriptName %>': <% if (!property.isRequired) { %>!exists(json, '<%- property.name %>') ? undefined : <% } %><% if (["date", "date-time"].includes(property.format) && property.isNullable) { %>json['<%- property.name %>'] === null ? null : <% } %><% if (["date", "date-time"].includes(property.format)) { %>(new Date(json['<%= property.name %>']))<% } else { %>json['<%= property.name %>']<% } %>,
<%_ } else if (property.export === 'array') { _%>
'<%= property.typescriptName %>': <% if (!property.isRequired) { %>!exists(json, '<%- property.name %>') ? undefined : <% } %><% if (property.isNullable) { %>json['<%- property.name %>'] === null ? null : <% } %><%= property.uniqueItems ? 'new Set(' : '' %>((json['<%= property.name %>'] as Array<any>).map(<%= property.type %>FromJSON))<%= property.uniqueItems ? ')' : '' %>,
<%_ } else if (property.export === 'dictionary') { _%>
'<%= property.typescriptName %>': <% if (!property.isRequired) { %>!exists(json, '<%- property.name %>') ? undefined : <% } %><% if (property.isNullable) { %>json['<%- property.name %>'] === null ? null : <% } %>(mapValues(json['<%= property.name %>'], <%= property.type %>FromJSON)),
<%_ } else { _%>
'<%= property.typescriptName %>': <% if (!property.isRequired) { %>!exists(json, '<%- property.name %>') ? undefined : <% } %><% if (property.isNullable) { %>json['<%- property.name %>'] === null ? null : <% } %><%= property.type %>FromJSON(json['<%= property.name %>']),
<%_ } _%>
'<%= property.typescriptName %>': <%- renderFromJsonValue(property) %>,
<%_ }); _%>
};
<%_ } _%>
Expand Down Expand Up @@ -173,24 +209,56 @@ export function <%= model.name %>ToJSON(value?: <%= model.name %> | null): any {
<%_ } else { _%>
return {

<%_
// Render code to convert a date to its string representation
const renderToJsonDateValue = (identifier, format) => {
return `${identifier}.toISOString()${format === 'date' ? '.substr(0,10)' : ''}`;
};
// Renders the appropriate nested function for .map() or mapValues() for arrays and dictionaries for the given type
const renderNestedToJsonValue = (type, depth = 0) => {
const itemIdentifier = `item${depth}`;
if (type.isPrimitive) {
return `(${itemIdentifier}) => ${["date", "date-time"].includes(type.format) ? renderToJsonDateValue(itemIdentifier, type.format) : itemIdentifier}`;
} else if (type.export === "array") {
return `(${itemIdentifier}) => ${itemIdentifier}.map(${renderNestedToJsonValue(type.link, depth + 1)})`;
} else if (type.export === "dictionary") {
return `(${itemIdentifier}) => mapValues(${itemIdentifier}, ${renderNestedToJsonValue(type.link, depth + 1)})`;
}
return `${type.name || type.type}ToJSON`;
};
// Renders the code to transform a property of the model to its json representation from the model types
const renderToJsonValue = (property) => {
const value = `value.${property.typescriptName}`;
let rendered = '';

if (canShortCircuitConversion(property)) {
rendered = value;
} else if (property.isPrimitive) {
rendered = ["date", "date-time"].includes(property.format) ? `(${renderToJsonDateValue(value, property.format)})` : value;
} else if (property.export === "array") {
const prefix = property.uniqueItems ? `Array.from(${value} as Array<any>)` : `(${value} as Array<any>)`;
rendered = `(${prefix}.map(${renderNestedToJsonValue(property.link)}))`;
} else if (property.export === "dictionary") {
rendered = `(mapValues(${value}, ${renderNestedToJsonValue(property.link)}))`;
} else if (property.type !== "any") {
rendered = `${property.type}ToJSON(${value})`;
} else {
rendered = value;
}

if ((property.isPrimitive && ["date", "date-time"].includes(property.format)) || (!property.isPrimitive && ["array", "dictionary"].includes(property.export))) {
rendered = property.isNullable ? `${value} === null ? null : ${rendered}` : rendered;
rendered = !property.isRequired ? `${value} === undefined ? undefined : ${rendered}` : rendered;
}
return rendered;
};
_%>
<%_ if (model.export === "dictionary") { _%>
...value,
<%_ } _%>
<%_ model.properties.forEach((property) => { _%>
<%_ if (!property.isReadOnly) { _%>
<%_ if (property.isPrimitive && ["date", "date-time"].includes(property.format)) { _%>
'<%= property.name %>': <% if (!property.isRequired) { %>value.<%- property.typescriptName %> === undefined ? undefined : <% } %>(<% if (property.isNullable) { %>value.<%- property.typescriptName %> === null ? null : <% } %>value.<%- property.typescriptName %>.toISOString()<% if (property.format === 'date') { %>.substr(0,10)<% } %>),
<%_ } else if (property.isPrimitive) { _%>
'<%= property.name %>': value.<%- property.typescriptName %>,
<%_ } else if (property.export === 'array') { _%>
'<%= property.name %>': <% if (!property.isRequired) { %>value.<%- property.typescriptName %> === undefined ? undefined : <% } %>(<% if (property.isNullable) { %>value.<%- property.typescriptName %> === null ? null : <% } %><% if (property.uniqueItems) { %>Array.from(value.<%- property.typescriptName %> as Set<any>)<% } else { %>(value.<%- property.typescriptName %> as Array<any>)<% } %>.map(<%- property.type %>ToJSON)),
<%_ } else if (property.export === 'dictionary') { _%>
'<%= property.name %>': <% if (!property.isRequired) { %>value.<%- property.typescriptName %> === undefined ? undefined : <% } %>(<% if (property.isNullable) { %>value.<%- property.typescriptName %> === null ? null : <% } %>mapValues(value.<%- property.typescriptName %>, <%- property.type %>ToJSON)),
<%_ } else if (property.type !== 'any') { _%>
'<%= property.name %>': <%- property.type %>ToJSON(value.<%- property.typescriptName %>),
<%_ } else { _%>
'<%= property.name %>': value.<%- property.typescriptName %>,
<%_ } _%>
'<%= property.name %>': <%- renderToJsonValue(property) %>,
<%_ } _%>
<%_ }); _%>
};
Expand Down
100 changes: 100 additions & 0 deletions packages/type-safe-api/test/resources/specs/edge-cases.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ArrayOfOneOfs"
/nested-collections:
post:
operationId: nestedCollections
responses:
200:
description: ok
content:
application/json:
schema:
$ref: "#/components/schemas/NestedCollections"
/additional-properties:
post:
operationId: dictionary
Expand Down Expand Up @@ -175,6 +185,96 @@ components:
type: array
items:
$ref: "#/components/schemas/NamedOneOfUnion"
NestedCollections:
type: object
properties:
nestedArrayOfStrings:
type: array
items:
type: array
items:
type: string
nestedArrayOfDates:
type: array
items:
type: array
items:
type: string
format: date
nestedArrayOfObjects:
type: array
items:
type: array
items:
$ref: "#/components/schemas/SomeObject"
fourDimensionalNestedArrayOfObjects:
type: array
items:
type: array
items:
type: array
items:
type: array
items:
$ref: "#/components/schemas/SomeObject"
nestedDictionaryOfStrings:
type: object
additionalProperties:
type: object
additionalProperties:
type: string
nestedDictionaryOfObjects:
type: object
additionalProperties:
type: object
additionalProperties:
$ref: "#/components/schemas/SomeObject"
fourDimensionalNestedDictionaryOfObjects:
type: object
additionalProperties:
type: object
additionalProperties:
type: object
additionalProperties:
type: object
additionalProperties:
$ref: "#/components/schemas/SomeObject"
nestedMixOfDictionariesAndArrays:
type: array
items:
type: object
additionalProperties:
type: array
items:
type: array
items:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/SomeObject"
cycleArray:
$ref: "#/components/schemas/CycleArray"
cycleDictionary:
$ref: "#/components/schemas/CycleDictionary"
CycleArray:
type: array
items:
$ref: "#/components/schemas/CycleArrayNode"
CycleArrayNode:
type: object
properties:
nodes:
$ref: "#/components/schemas/CycleArray"
CycleDictionary:
type: object
additionalProperties:
$ref: "#/components/schemas/CycleDictionaryNode"
CycleDictionaryNode:
type: object
properties:
nodes:
$ref: "#/components/schemas/CycleDictionary"
AdditionalPropertiesResponse:
type: object
properties:
Expand Down
Loading
Loading