Skip to content

Commit 44999ac

Browse files
committed
Continue implementing OpenAPI 3.2.0
1 parent 4bca767 commit 44999ac

File tree

57 files changed

+3178
-386
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+3178
-386
lines changed

src/analysis/form-model.builder.ts

Lines changed: 157 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { FormProperty, Resource, SwaggerDefinition } from "@src/core/types/index.js";
22
import { SwaggerParser } from '@src/core/parser.js';
3-
import { getTypeScriptType, pascalCase, singular } from "@src/core/utils/index.js";
3+
import { camelCase, getTypeScriptType, pascalCase, singular } from "@src/core/utils/index.js";
44
import { analyzeValidationRules } from "./validation.analyzer.js";
55

6-
import { FormAnalysisResult, FormControlModel } from "./form-types.js";
6+
import { FormAnalysisResult, FormControlModel, PolymorphicPropertyConfig } from "./form-types.js";
77

88
export class FormModelBuilder {
99
private parser: SwaggerParser;
@@ -13,7 +13,10 @@ export class FormModelBuilder {
1313
usesCustomValidators: false,
1414
hasFormArrays: false,
1515
hasFileUploads: false,
16-
isPolymorphic: false
16+
hasMaps: false,
17+
isPolymorphic: false,
18+
polymorphicProperties: [],
19+
dependencyRules: []
1720
};
1821

1922
constructor(parser: SwaggerParser) {
@@ -22,30 +25,75 @@ export class FormModelBuilder {
2225

2326
public build(resource: Resource): FormAnalysisResult {
2427
const formInterfaceName = `${pascalCase(resource.modelName)}Form`;
28+
const definitions = this.parser.schemas;
2529

2630
// 1. Detect Polymorphism
27-
const oneOfProp = resource.formProperties.find(p => p.schema.oneOf && p.schema.discriminator);
28-
if (oneOfProp) {
31+
const polymorphicProps = resource.formProperties.filter(p => p.schema.oneOf && p.schema.discriminator);
32+
33+
if (polymorphicProps.length > 0) {
2934
this.result.isPolymorphic = true;
30-
this.result.discriminatorPropName = oneOfProp.schema.discriminator!.propertyName;
31-
this.analyzePolymorphism(oneOfProp);
35+
36+
for (const prop of polymorphicProps) {
37+
const config = this.analyzePolymorphism(prop);
38+
if (config) {
39+
this.result.polymorphicProperties.push(config);
40+
}
41+
}
3242
}
3343

34-
// 2. Build Top Level Controls & Interfaces
35-
// This method recursively populates this.result.interfaces
44+
// 2. Detect Dependent Schemas (Optimization: check generic schema lookup for this model)
45+
const modelDef = definitions.find(d => d.name === resource.modelName)?.definition;
46+
if (modelDef && modelDef.dependentSchemas) {
47+
this.analyzeDependentSchemas(modelDef);
48+
}
49+
// Also check if the resource properties themselves originated from a schema with dependentSchemas
50+
// (Handling cases where the Resource object was built from flattened paths)
51+
/*for (const prop of resource.formProperties) {
52+
// We can't easily climb back up to the parent form schema from a property alone here without context,
53+
// but the previous check covers the main model definition which is the primary source for forms.
54+
}*/
55+
56+
// 3. Build Top Level Controls & Interfaces
3657
this.result.topLevelControls = this.analyzeControls(
3758
resource.formProperties,
3859
formInterfaceName,
3960
true
4061
);
4162

42-
// 3. Global Flags
63+
// 4. Global Flags
4364
this.result.hasFileUploads = resource.formProperties.some(p => p.schema.format === 'binary');
44-
// hasFormArrays logic is handled inside analyzeControls where recursion happens
4565

4666
return this.result;
4767
}
4868

69+
private analyzeDependentSchemas(modelSchema: SwaggerDefinition) {
70+
if (!modelSchema.dependentSchemas) return;
71+
72+
Object.entries(modelSchema.dependentSchemas).forEach(([triggerProp, schemaOrRef]) => {
73+
const dependentSchema = this.parser.resolve(schemaOrRef);
74+
if (!dependentSchema) return;
75+
76+
// Start with 'required' array
77+
if (dependentSchema.required) {
78+
dependentSchema.required.forEach(reqProp => {
79+
this.result.dependencyRules.push({
80+
triggerField: triggerProp,
81+
targetField: reqProp,
82+
type: 'required'
83+
});
84+
});
85+
}
86+
87+
// Also check for nested properties that implicitly become required
88+
if (dependentSchema.properties) {
89+
// For simply defining the property structure, we don't necessarily force 'required'
90+
// unless it's in the 'required' array. However, if the property only exists via dependent schema,
91+
// UI might want to toggle visibility.
92+
// For now, we strictly follow JSON Schema 'required' semantics for validation logic.
93+
}
94+
});
95+
}
96+
4997
/**
5098
* Recursively analyzes properties to build Control Models and Interface Definitions.
5199
*/
@@ -58,12 +106,10 @@ export class FormModelBuilder {
58106
const interfaceProps: { name: string }[] = [];
59107

60108
for (const prop of properties) {
61-
if (prop.schema.readOnly) continue;
62-
63109
const schema = prop.schema;
64110
const validationRules = analyzeValidationRules(schema);
65111

66-
if (validationRules.some(r => ['exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'uniqueItems'].includes(r.type))) {
112+
if (validationRules.some(r => ['exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'uniqueItems', 'not'].includes(r.type))) {
67113
this.result.usesCustomValidators = true;
68114
}
69115

@@ -93,16 +139,61 @@ export class FormModelBuilder {
93139
schema
94140
};
95141
}
96-
// 2b. Form Array
142+
// 2b. Map / Dictionary (additionalProperties OR patternProperties)
143+
else if (schema.type === 'object' && !schema.properties && (schema.additionalProperties || schema.unevaluatedProperties || schema.patternProperties)) {
144+
this.result.hasMaps = true;
145+
146+
// extract the schema for map values
147+
let rawValueSchema: any = {};
148+
let keyPattern: string | undefined;
149+
150+
if (schema.patternProperties) {
151+
const patterns = Object.keys(schema.patternProperties);
152+
if (patterns.length > 0) {
153+
keyPattern = patterns[0]; // Using first pattern as constraint for the KEY
154+
rawValueSchema = schema.patternProperties[keyPattern];
155+
}
156+
}
157+
158+
if (Object.keys(rawValueSchema).length === 0) {
159+
rawValueSchema = (typeof schema.additionalProperties === 'object' ? schema.additionalProperties : undefined)
160+
|| (typeof schema.unevaluatedProperties === 'object' ? schema.unevaluatedProperties : undefined)
161+
|| {};
162+
}
163+
164+
const valuePropName = 'value';
165+
const valueInterfacePrefix = `${pascalCase(prop.name)}Value`;
166+
167+
const valueControls = this.analyzeControls(
168+
[{ name: valuePropName, schema: rawValueSchema as SwaggerDefinition }],
169+
`${valueInterfacePrefix}Form`,
170+
false
171+
);
172+
173+
const valueControl = valueControls[0]!;
174+
const valueTsType = valueControl.dataType;
175+
176+
controlModel = {
177+
name: prop.name,
178+
propertyName: prop.name,
179+
dataType: isValidTsType(valueTsType) ? `Record<string, ${valueTsType}>` : `Record<string, any>`,
180+
defaultValue: defaultValue || {},
181+
validationRules,
182+
controlType: 'map',
183+
mapValueControl: valueControl,
184+
schema,
185+
...(valueControl.nestedFormInterface && { nestedFormInterface: valueControl.nestedFormInterface }),
186+
...(keyPattern && { keyPattern }) // Attach the pattern for renderer usage
187+
};
188+
}
189+
// 2c. Form Array
97190
else if (schema.type === 'array') {
98191
const itemSchema = schema.items as SwaggerDefinition;
99192

100193
if (itemSchema?.properties) {
101-
// Array of Objects (Complex)
102194
this.result.hasFormArrays = true;
103195
const arrayItemInterfaceName = `${pascalCase(singular(prop.name))}Form`;
104196

105-
// Recurse for item structure (phantom call to generate interface)
106197
const nestedItemControls = this.analyzeControls(
107198
Object.entries(itemSchema.properties).map(([k, v]) => ({ name: k, schema: v })),
108199
arrayItemInterfaceName,
@@ -116,12 +207,11 @@ export class FormModelBuilder {
116207
defaultValue,
117208
validationRules,
118209
controlType: 'array',
119-
nestedFormInterface: arrayItemInterfaceName, // References the Item interface
120-
nestedControls: nestedItemControls, // Stored for "createItem" helper generation
210+
nestedFormInterface: arrayItemInterfaceName,
211+
nestedControls: nestedItemControls,
121212
schema
122213
};
123214
} else {
124-
// Array of Primitives
125215
const itemTsType = this.getFormControlTypeString(itemSchema);
126216

127217
controlModel = {
@@ -135,7 +225,7 @@ export class FormModelBuilder {
135225
};
136226
}
137227
}
138-
// 2c. Primitive Control
228+
// 2d. Primitive Control
139229
else {
140230
const tsType = this.getFormControlTypeString(schema);
141231

@@ -153,7 +243,6 @@ export class FormModelBuilder {
153243
controls.push(controlModel);
154244
}
155245

156-
// Register the interface
157246
this.result.interfaces.push({
158247
name: interfaceName,
159248
properties: interfaceProps,
@@ -163,32 +252,36 @@ export class FormModelBuilder {
163252
return controls;
164253
}
165254

166-
private analyzePolymorphism(prop: FormProperty) {
255+
private analyzePolymorphism(prop: FormProperty): PolymorphicPropertyConfig | null {
256+
if (!prop.schema.discriminator) return null;
257+
167258
const options = this.parser.getPolymorphicSchemaOptions(prop.schema);
168-
this.result.discriminatorOptions = options.map(o => o.name);
169-
this.result.polymorphicOptions = [];
259+
const dPropName = prop.schema.discriminator.propertyName;
170260

171-
const dPropName = prop.schema.discriminator!.propertyName;
261+
const config: PolymorphicPropertyConfig = {
262+
propertyName: dPropName,
263+
discriminatorOptions: options.map(o => o.name),
264+
options: []
265+
};
172266

173-
const oneOfHasObjects = prop.schema.oneOf!.some(s => this.parser.resolve(s)?.properties);
174-
if (!oneOfHasObjects) return;
267+
const explicitMapping = prop.schema.discriminator?.mapping || {};
175268

176-
for (const subSchemaRef of prop.schema.oneOf!) {
177-
if (!subSchemaRef.$ref) continue;
269+
for (const subSchemaRef of prop.schema.oneOf || []) {
270+
const refString = subSchemaRef.$ref || subSchemaRef.$dynamicRef;
271+
if (!refString) continue;
178272

179273
const subSchema = this.parser.resolve(subSchemaRef);
180274
if (!subSchema) continue;
181275

182276
const allProperties: Record<string, SwaggerDefinition> = { ...(subSchema.properties || {}) };
183277

184-
// **THE FIX**: Recursively collect properties from allOf references
185278
const collectAllOfProps = (schema: SwaggerDefinition) => {
186279
if (schema.allOf) {
187280
for (const inner of schema.allOf) {
188281
const resolved = this.parser.resolve(inner);
189282
if (resolved) {
190283
Object.assign(allProperties, resolved.properties || {});
191-
collectAllOfProps(resolved); // Recurse
284+
collectAllOfProps(resolved);
192285
}
193286
}
194287
}
@@ -197,42 +290,64 @@ export class FormModelBuilder {
197290

198291
if (Object.keys(allProperties).length === 0) continue;
199292

200-
const typeName = allProperties[dPropName]?.enum?.[0] as string;
293+
if (!allProperties[dPropName]) continue;
294+
295+
let typeName = allProperties[dPropName]?.enum?.[0] as string;
296+
297+
if (!typeName) {
298+
const mappedKey = Object.keys(explicitMapping).find(key => explicitMapping[key] === refString);
299+
if (mappedKey) typeName = mappedKey;
300+
}
301+
302+
if (!typeName) {
303+
typeName = refString.split('/').pop() || '';
304+
}
305+
201306
if (!typeName) continue;
202307

203-
const refName = pascalCase(subSchemaRef.$ref.split('/').pop()!);
308+
const refName = pascalCase(refString.split('/').pop()!);
204309

205310
const subProperties = Object.entries(allProperties)
206-
.filter(([key, schema]) => key !== dPropName && !schema.readOnly)
311+
.filter(([key, _schema]) => key !== dPropName)
207312
.map(([key, s]) => ({ name: key, schema: s as SwaggerDefinition }));
208313

209314
const subControls: FormControlModel[] = [];
210315
for (const subProp of subProperties) {
211316
const controls = this.analyzeControls([subProp], `Temp${refName}`, false);
212-
this.result.interfaces.pop();
317+
this.result.interfaces.pop(); // Remove temp interface
213318
subControls.push(...controls);
214319
}
215320

216-
this.result.polymorphicOptions.push({
321+
if (!config.discriminatorOptions.includes(typeName)) {
322+
config.discriminatorOptions.push(typeName);
323+
}
324+
325+
config.options.push({
217326
discriminatorValue: typeName,
218327
modelName: refName,
219-
subFormName: typeName.toLowerCase(), // Ensure sub-form name is consistent (e.g., 'cat', 'dog')
328+
subFormName: camelCase(typeName),
220329
controls: subControls
221330
});
222331
}
223332

224-
// Detect default mapping
225333
if (prop.schema.discriminator?.defaultMapping) {
226334
const defaultName = pascalCase(prop.schema.discriminator.defaultMapping.split('/').pop() || '');
227-
if (defaultName && this.result.polymorphicOptions.some(p => p.modelName === defaultName)) {
228-
this.result.defaultPolymorphicOption = defaultName;
335+
if (defaultName && config.options.some(p => p.modelName === defaultName)) {
336+
config.defaultOption = defaultName;
229337
}
230338
}
339+
340+
return config;
231341
}
232342

233343
private getFormControlTypeString(schema: SwaggerDefinition): string {
234344
const knownTypes = this.parser.schemas.map(s => s.name);
235-
const type = getTypeScriptType(schema, { options: { dateType: 'Date' } } as any, knownTypes);
345+
const dummyConfig = { options: { dateType: 'Date', enumStyle: 'enum' } } as any;
346+
const type = getTypeScriptType(schema, dummyConfig, knownTypes);
236347
return `${type} | null`;
237348
}
238349
}
350+
351+
function isValidTsType(type: string): boolean {
352+
return type != null && type !== 'any' && type !== 'void';
353+
}

0 commit comments

Comments
 (0)