Skip to content
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 @@ -746,6 +746,7 @@ private static List<String> splitAdditionalModelOption(String value) {
@Override
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
objs = super.postProcessAllModels(objs);
markModelClassRendering(objs);
if (additionalModelObjectAttributes.isEmpty()
&& additionalModelEnumAttributes.isEmpty()
&& additionalModelImports.isEmpty()) {
Expand All @@ -766,6 +767,62 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
return objs;
}

/** Models on an inline reference cycle become final classes: a struct that stores itself has infinite size (#15240). */
private void markModelClassRendering(Map<String, ModelsMap> objs) {
Map<String, CodegenModel> modelsByClassname = new HashMap<>();
for (ModelsMap modelsMap : objs.values()) {
for (ModelMap modelMap : modelsMap.getModels()) {
CodegenModel cm = modelMap.getModel();
modelsByClassname.put(cm.classname, cm);
}
}

Map<String, Set<String>> inlineRefs = new HashMap<>();
for (CodegenModel cm : modelsByClassname.values()) {
Set<String> refs = new LinkedHashSet<>();
collectInlineModelRefs(cm.allVars, modelsByClassname, refs);
if (cm.getComposedSchemas() != null) {
// oneOf/anyOf render as enums with inline associated values, so they carry the
// recursion; allOf is flattened into allVars and is deliberately not an edge
collectInlineModelRefs(cm.getComposedSchemas().getOneOf(), modelsByClassname, refs);
collectInlineModelRefs(cm.getComposedSchemas().getAnyOf(), modelsByClassname, refs);
}
inlineRefs.put(cm.classname, refs);
}

for (CodegenModel cm : modelsByClassname.values()) {
if (useClasses || isOnInlineReferenceCycle(cm.classname, inlineRefs)) {
cm.vendorExtensions.put("x-swift-use-class", true);
}
}
}

private void collectInlineModelRefs(List<CodegenProperty> vars, Map<String, CodegenModel> modelsByClassname, Set<String> refs) {
if (vars == null) {
return;
}
for (CodegenProperty var : vars) {
if (!var.isContainer && var.complexType != null && modelsByClassname.containsKey(var.complexType)) {
refs.add(var.complexType);
}
}
}

private boolean isOnInlineReferenceCycle(String classname, Map<String, Set<String>> inlineRefs) {
Deque<String> toVisit = new ArrayDeque<>(inlineRefs.getOrDefault(classname, Collections.emptySet()));
Set<String> visited = new HashSet<>();
while (!toVisit.isEmpty()) {
String current = toVisit.pop();
if (classname.equals(current)) {
return true;
}
if (visited.add(current)) {
toVisit.addAll(inlineRefs.getOrDefault(current, Collections.emptySet()));
}
}
return false;
}

@Override
protected boolean isReservedWord(String word) {
return word != null && reservedWords.contains(word); //don't lowercase as super does
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,7 @@ private static List<String> splitAdditionalModelOption(String value) {
@Override
public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs) {
objs = super.postProcessAllModels(objs);
markModelClassRendering(objs);
if (additionalModelObjectAttributes.isEmpty()
&& additionalModelEnumAttributes.isEmpty()
&& additionalModelImports.isEmpty()) {
Expand All @@ -814,6 +815,69 @@ public Map<String, ModelsMap> postProcessAllModels(Map<String, ModelsMap> objs)
return objs;
}

/** Models on an inline reference cycle become final classes: a struct that stores itself has infinite size (#15240). */
private void markModelClassRendering(Map<String, ModelsMap> objs) {
Map<String, CodegenModel> modelsByClassname = new HashMap<>();
for (ModelsMap modelsMap : objs.values()) {
for (ModelMap modelMap : modelsMap.getModels()) {
CodegenModel cm = modelMap.getModel();
modelsByClassname.put(cm.classname, cm);
}
}

Map<String, Set<String>> inlineRefs = new HashMap<>();
for (CodegenModel cm : modelsByClassname.values()) {
Set<String> refs = new LinkedHashSet<>();
collectInlineModelRefs(cm.allVars, modelsByClassname, refs);
if (cm.getComposedSchemas() != null) {
// oneOf/anyOf render as enums with inline associated values, so they carry the
// recursion; allOf is flattened into allVars and is deliberately not an edge
collectInlineModelRefs(cm.getComposedSchemas().getOneOf(), modelsByClassname, refs);
collectInlineModelRefs(cm.getComposedSchemas().getAnyOf(), modelsByClassname, refs);
}
inlineRefs.put(cm.classname, refs);
}

for (CodegenModel cm : modelsByClassname.values()) {
boolean recursive = !useClasses && isOnInlineReferenceCycle(cm.classname, inlineRefs);
if (useClasses || recursive) {
cm.vendorExtensions.put("x-swift-use-class", true);
}
if ((useClasses && readonlyProperties) || recursive) {
// a struct embedding one of these classes is still declared Sendable, so the
// class must conform; a recursion-breaking class is treated as unchecked the
// way readonlyProperties classes already are
cm.vendorExtensions.put("x-swift-unchecked-sendable", true);
}
}
}

private void collectInlineModelRefs(List<CodegenProperty> vars, Map<String, CodegenModel> modelsByClassname, Set<String> refs) {
if (vars == null) {
return;
}
for (CodegenProperty var : vars) {
if (!var.isContainer && var.complexType != null && modelsByClassname.containsKey(var.complexType)) {
refs.add(var.complexType);
}
}
}

private boolean isOnInlineReferenceCycle(String classname, Map<String, Set<String>> inlineRefs) {
Deque<String> toVisit = new ArrayDeque<>(inlineRefs.getOrDefault(classname, Collections.emptySet()));
Set<String> visited = new HashSet<>();
while (!toVisit.isEmpty()) {
String current = toVisit.pop();
if (classname.equals(current)) {
return true;
}
if (visited.add(current)) {
toVisit.addAll(inlineRefs.getOrDefault(current, Collections.emptySet()));
}
}
return false;
}

@Override
protected boolean isReservedWord(String word) {
return word != null && reservedWords.contains(word); //don't lowercase as super does
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1574,6 +1574,14 @@ public ExtendedCodegenParameter(CodegenParameter cp) {
this.isPrimitiveType = cp.isPrimitiveType;
this.isModel = cp.isModel;
this.isExplode = cp.isExplode;
this.isExplode = cp.isExplode;
this.isDeepObject = cp.isDeepObject;
this.isFormStyle = cp.isFormStyle;
this.isMatrix = cp.isMatrix;
this.isAllowEmptyValue = cp.isAllowEmptyValue;
this.isSpaceDelimited = cp.isSpaceDelimited;
this.isPipeDelimited = cp.isPipeDelimited;

this.baseName = cp.baseName;
this.paramName = cp.paramName;
this.dataType = cp.dataType;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{{#additionalModelObjectAttributes}}{{{.}}}
{{/additionalModelObjectAttributes}}{{^objcCompatible}}{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} {{#useClasses}}final class{{/useClasses}}{{^useClasses}}struct{{/useClasses}} {{{classname}}}: {{#useVapor}}Content{{/useVapor}}{{^useVapor}}Codable{{#useJsonEncodable}}, JSONEncodable{{/useJsonEncodable}}{{/useVapor}}{{#vendorExtensions.x-swift-hashable}}, Hashable{{/vendorExtensions.x-swift-hashable}} {
{{/additionalModelObjectAttributes}}{{^objcCompatible}}{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} {{#vendorExtensions.x-swift-use-class}}final class{{/vendorExtensions.x-swift-use-class}}{{^vendorExtensions.x-swift-use-class}}struct{{/vendorExtensions.x-swift-use-class}} {{{classname}}}: {{#useVapor}}Content{{/useVapor}}{{^useVapor}}Codable{{#useJsonEncodable}}, JSONEncodable{{/useJsonEncodable}}{{/useVapor}}{{#vendorExtensions.x-swift-hashable}}, Hashable{{/vendorExtensions.x-swift-hashable}} {
{{/objcCompatible}}{{#objcCompatible}}@objcMembers {{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} class {{classname}}: NSObject, Codable{{#useJsonEncodable}}, JSONEncodable{{/useJsonEncodable}} {
{{/objcCompatible}}

Expand Down Expand Up @@ -121,7 +121,7 @@
{{/allVars}}
let additionalPropertiesContainer = try decoder.container(keyedBy: String.self)
additionalProperties = try additionalPropertiesContainer.decodeMap({{{additionalPropertiesType}}}.self, excludedKeys: nonAdditionalPropertyKeys)
}{{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}{{^objcCompatible}}{{#useClasses}}{{#vendorExtensions.x-swift-hashable}}
}{{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}{{^objcCompatible}}{{#vendorExtensions.x-swift-use-class}}{{#vendorExtensions.x-swift-hashable}}

{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} static func == (lhs: {{classname}}, rhs: {{classname}}) -> Bool {
{{#allVars}}
Expand All @@ -135,5 +135,5 @@
hasher.combine({{{name}}}{{^vendorExtensions.x-null-encodable}}{{^required}}?{{/required}}{{/vendorExtensions.x-null-encodable}}.hashValue)
{{/allVars}}
{{#generateModelAdditionalProperties}}{{#additionalPropertiesType}}hasher.combine(additionalProperties.hashValue){{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}
}{{/vendorExtensions.x-swift-hashable}}{{/useClasses}}{{/objcCompatible}}
}{{/vendorExtensions.x-swift-hashable}}{{/vendorExtensions.x-swift-use-class}}{{/objcCompatible}}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{{#additionalModelObjectAttributes}}{{{.}}}
{{/additionalModelObjectAttributes}}{{^objcCompatible}}{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} {{#useClasses}}final class{{/useClasses}}{{^useClasses}}struct{{/useClasses}} {{{classname}}}: {{^useClasses}}Sendable, {{/useClasses}}{{#useClasses}}{{#readonlyProperties}}@unchecked Sendable, {{/readonlyProperties}}{{/useClasses}}{{#useVapor}}Content{{/useVapor}}{{^useVapor}}Codable{{/useVapor}}{{#vendorExtensions.x-swift-hashable}}, Hashable{{/vendorExtensions.x-swift-hashable}} {
{{/additionalModelObjectAttributes}}{{^objcCompatible}}{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} {{#vendorExtensions.x-swift-use-class}}final class{{/vendorExtensions.x-swift-use-class}}{{^vendorExtensions.x-swift-use-class}}struct{{/vendorExtensions.x-swift-use-class}} {{{classname}}}: {{^vendorExtensions.x-swift-use-class}}Sendable, {{/vendorExtensions.x-swift-use-class}}{{#vendorExtensions.x-swift-unchecked-sendable}}@unchecked Sendable, {{/vendorExtensions.x-swift-unchecked-sendable}}{{#useVapor}}Content{{/useVapor}}{{^useVapor}}Codable{{/useVapor}}{{#vendorExtensions.x-swift-hashable}}, Hashable{{/vendorExtensions.x-swift-hashable}} {
{{/objcCompatible}}{{#objcCompatible}}@objcMembers {{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} final class {{classname}}: NSObject, Codable, @unchecked Sendable {
{{/objcCompatible}}

Expand Down Expand Up @@ -121,7 +121,7 @@
{{/allVars}}
let additionalPropertiesContainer = try decoder.container(keyedBy: String.self)
additionalProperties = try additionalPropertiesContainer.decodeMap({{{additionalPropertiesType}}}.self, excludedKeys: nonAdditionalPropertyKeys)
}{{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}{{^objcCompatible}}{{#useClasses}}{{#vendorExtensions.x-swift-hashable}}
}{{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}{{^objcCompatible}}{{#vendorExtensions.x-swift-use-class}}{{#vendorExtensions.x-swift-hashable}}

{{#nonPublicApi}}internal{{/nonPublicApi}}{{^nonPublicApi}}public{{/nonPublicApi}} static func == (lhs: {{classname}}, rhs: {{classname}}) -> Bool {
{{#allVars}}
Expand All @@ -135,5 +135,5 @@
hasher.combine({{{name}}}{{^vendorExtensions.x-null-encodable}}{{^required}}?{{/required}}{{/vendorExtensions.x-null-encodable}}.hashValue)
{{/allVars}}
{{#generateModelAdditionalProperties}}{{#additionalPropertiesType}}hasher.combine(additionalProperties.hashValue){{/additionalPropertiesType}}{{/generateModelAdditionalProperties}}
}{{/vendorExtensions.x-swift-hashable}}{{/useClasses}}{{/objcCompatible}}
}{{/vendorExtensions.x-swift-hashable}}{{/vendorExtensions.x-swift-use-class}}{{/objcCompatible}}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,13 @@ new Configuration({
[parameter-locations-url]: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#parameter-locations
[style-values-url]: https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#style-values
[@honoluluhenk/http-param-expander]: https://www.npmjs.com/package/@honoluluhenk/http-param-expander

## Deep-object query parameters

For `style: deepObject`, nested objects are serialized using bracket notation, for example
`filter[name][contains]=Alice`. Arrays and sets use zero-based indices, for example
`filter[sort][0][field]=name`. Dates are serialized as ISO strings; null and undefined
values and empty containers are omitted.

OpenAPI only specifies deepObject serialization for flat objects. This recursive
encoding is a generator extension and requires a server that accepts bracket notation.
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class BaseService {
}

return Object.keys(value as Record<string, any>).reduce(
(hp, k) => hp.append(`${key}[${k}]`, value[k]),
(hp, k) => this.addToHttpParamsDeepObject(hp, `${key}[${k}]`, value[k]),
httpParams,
);
} else if (paramStyle === QueryParamStyle.Json) {
Expand Down Expand Up @@ -85,4 +85,28 @@ export class BaseService {
}
}
}

/**
* Nested deepObject values are an extension to OpenAPI's flat-object serialization.
* Object properties use bracket notation; array and set elements use numeric indices.
*/
private addToHttpParamsDeepObject(httpParams: OpenApiHttpParams, key: string, value: any): OpenApiHttpParams {
if (value === null || value === undefined) {
return httpParams;
} else if (value instanceof Date) {
return httpParams.append(key, value.toISOString());
} else if (Array.isArray(value) || value instanceof Set) {
const array = Array.isArray(value) ? value : Array.from(value);
return array.reduce(
(hp, item, index) => this.addToHttpParamsDeepObject(hp, `${key}[${index}]`, item),
httpParams,
);
} else if (typeof value === 'object') {
return Object.keys(value).reduce(
(hp, k) => this.addToHttpParamsDeepObject(hp, `${key}[${k}]`, value[k]),
httpParams,
);
}
return httpParams.append(key, value.toString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,23 @@ export class {{classname}} extends runtime.BaseAPI {
{{^isArray}}
if (requestParameters['{{paramName}}'] != null) {
{{#isExplode}}
{{#isContainer}}
{{! form + explode: one parameter per entry. isMap (not isContainer) also covers free-form objects; deepObject is assigned whole so the runtime brackets it. }}
{{#isDeepObject}}
{{>apisAssignQueryParam}}
{{/isDeepObject}}
{{^isDeepObject}}
{{#isMap}}
for (let key of Object.keys(requestParameters['{{paramName}}'])) {
queryParameters[key] = requestParameters['{{paramName}}'][key];
const value = (requestParameters['{{paramName}}'] as any)[key];
if (value != null) {
Object.defineProperty(queryParameters, key, { value, enumerable: true, writable: true, configurable: true });
}
}
{{/isContainer}}
{{^isContainer}}
{{/isMap}}
{{^isMap}}
{{>apisAssignQueryParam}}
{{/isContainer}}
{{/isMap}}
{{/isDeepObject}}
{{/isExplode}}
{{^isExplode}}
{{>apisAssignQueryParam}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,31 @@ public void testPodAuthors() throws Exception {
Assert.assertEquals(podAuthors, openAPIDevs);
}

@Test(description = "models on an inline reference cycle become classes, everything else stays a struct")
public void testRecursiveModelsBecomeClasses() throws IOException {
Path target = Files.createTempDirectory("test");
target.toFile().deleteOnExit();
final CodegenConfigurator configurator = new CodegenConfigurator()
.setGeneratorName("swift5")
.setInputSpec("src/test/resources/3_0/swift/recursive-models.yaml")
.setOutputDir(target.toAbsolutePath().toString());
new DefaultGenerator().opts(configurator.toClientOptInput()).generate();

Path models = target.resolve("OpenAPIClient/Classes/OpenAPIs/Models");
// a struct that stores itself inline has infinite size and does not compile (#15240):
// the self-referencing model and both halves of the mutual cycle become final classes
TestUtils.assertFileContains(models.resolve("ContactInfo.swift"), "public final class ContactInfo:");
TestUtils.assertFileContains(models.resolve("NodeA.swift"), "public final class NodeA:");
TestUtils.assertFileContains(models.resolve("NodeB.swift"), "public final class NodeB:");
// embedding a cyclic class costs nothing, and containers already give heap
// indirection - these stay structs
TestUtils.assertFileContains(models.resolve("DomainInfo.swift"), "public struct DomainInfo:");
TestUtils.assertFileContains(models.resolve("Category.swift"), "public struct Category:");
// allOf is flattened rather than stored, so it is not an edge: only Derived is on a cycle
TestUtils.assertFileContains(models.resolve("Derived.swift"), "public final class Derived:");
TestUtils.assertFileContains(models.resolve("Base.swift"), "public struct Base:");
}

@Test(description = "Bug example code generation", enabled = true)
public void crashSwift5ExampleCodeGenerationStackOverflowTest() throws IOException {
//final OpenAPI openAPI = TestUtils.parseFlattenSpec("src/test/resources/bugs/Swift5CodeGenerationStackOverflow#2966.yaml");
Expand Down
Loading
Loading