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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"bugs": "https://github.com/the-codegen-project/cli/issues",
"dependencies": {
"@asyncapi/avro-schema-parser": "^3.0.24",
"@asyncapi/modelina": "^6.0.0-next.7",
"@asyncapi/modelina": "^6.0.0-next.8",
"@asyncapi/openapi-schema-parser": "^3.0.24",
"@asyncapi/parser": "^3.4.0",
"@asyncapi/protobuf-schema-parser": "^3.5.1",
Expand Down
105 changes: 100 additions & 5 deletions src/codegen/modelina/presets/primitives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
ConstrainedIntegerModel,
ConstrainedFloatModel,
ConstrainedBooleanModel,
ConstrainedArrayModel
ConstrainedArrayModel,
ConstrainedAnyModel,
ConstrainedUnionModel
} from '@asyncapi/modelina';
import {TypeScriptRenderer} from '@asyncapi/modelina/lib/types/generators/typescript/TypeScriptRenderer';
import {
Expand Down Expand Up @@ -32,6 +34,55 @@ function isPrimitiveModel(model: ConstrainedMetaModel): boolean {
);
}

/**
* Check if the model is a null type (ConstrainedAnyModel with type: null in schema)
* Modelina converts null types to ConstrainedAnyModel
*/
function isNullModel(model: ConstrainedMetaModel): boolean {
if (!(model instanceof ConstrainedAnyModel)) {
return false;
}
// Check if the original input schema has type: null
const originalInput = model.originalInput;
return originalInput && originalInput.type === 'null';
}

/**
* Check if the model is a date format string type (date or date-time)
* Note: 'time' format is NOT included because RFC 3339 time strings like "14:30:00"
* are not valid Date constructor arguments in JavaScript and would produce Invalid Date.
* Time values should remain as strings.
*/
function isDateFormatModel(model: ConstrainedMetaModel): boolean {
if (!(model instanceof ConstrainedStringModel)) {
return false;
}
const format = model.originalInput?.format;
return format === 'date' || format === 'date-time';
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FormatTime unmarshal returns string despite Date type

Medium Severity

The isDateFormatModel function correctly excludes time format from new Date() wrapping because time-only strings produce Invalid Date. However, Modelina still generates type FormatTime = Date for format: "time" schemas. The resulting unmarshal uses the primitive path (JSON.parse(json) as FormatTime), returning a string unsafely cast to Date. Any caller using Date methods on the result will get runtime errors. Either the time format needs new Date() wrapping or Modelina's type mapping needs to produce string instead of Date for time-only formats.

Additional Locations (1)

Fix in Cursor Fix in Web


/**
* Render marshal function for null type
*/
function renderNullMarshal(model: ConstrainedMetaModel): string {
return `export function marshal(payload: null): string {
return JSON.stringify(payload);
}`;
}

/**
* Render unmarshal function for null type
*/
function renderNullUnmarshal(model: ConstrainedMetaModel): string {
return `export function unmarshal(json: string): null {
const parsed = JSON.parse(json);
if (parsed !== null) {
throw new Error('Expected null value');
}
return null;
}`;
}

/**
* Render marshal function for primitive types
*/
Expand All @@ -52,6 +103,27 @@ function renderPrimitiveUnmarshal(model: ConstrainedMetaModel): string {
}`;
}

/**
* Render unmarshal function for date format types
* Converts JSON string to JavaScript Date object
*/
function renderDateUnmarshal(model: ConstrainedMetaModel): string {
return `export function unmarshal(json: string): ${model.name} {
const parsed = JSON.parse(json);
return new Date(parsed);
}`;
}

/**
* Render marshal function for date format types
* Note: JSON.stringify(Date) calls Date.toJSON() which returns ISO string
*/
function renderDateMarshal(model: ConstrainedMetaModel): string {
return `export function marshal(payload: ${model.name}): string {
return JSON.stringify(payload);
}`;
}

/**
* Render marshal function for array types
*/
Expand Down Expand Up @@ -86,11 +158,15 @@ function renderArrayMarshal(model: ConstrainedArrayModel): string {
function renderArrayUnmarshal(model: ConstrainedArrayModel): string {
const valueModel = model.valueModel;

// Check if array items have an unmarshal method (object types)
// Check if array items have an unmarshal method (only object types do)
// Exclude primitives, nested arrays, and union types - they don't have unmarshal methods
// Union types are just type aliases without static unmarshal methods
const hasItemUnmarshal =
valueModel.type !== 'string' &&
valueModel.type !== 'number' &&
valueModel.type !== 'boolean';
valueModel.type !== 'boolean' &&
!(valueModel instanceof ConstrainedArrayModel) &&
!(valueModel instanceof ConstrainedUnionModel);

if (hasItemUnmarshal) {
const itemTypeName = valueModel.name;
Expand Down Expand Up @@ -145,10 +221,19 @@ export function createPrimitivesPreset(
}) {
// Handle primitive types (string, integer, float, boolean)
if (isPrimitiveModel(model)) {
// Use date-specific marshal/unmarshal for date formats
const isDate = isDateFormatModel(model);
const unmarshalFunc = isDate
? renderDateUnmarshal(model)
: renderPrimitiveUnmarshal(model);
const marshalFunc = isDate
? renderDateMarshal(model)
: renderPrimitiveMarshal(model);

return `${content}

${renderPrimitiveUnmarshal(model)}
${renderPrimitiveMarshal(model)}
${unmarshalFunc}
${marshalFunc}
${options.includeValidation ? generateTypescriptValidationCode({model, renderer, asClassMethods: false, context: context as any}) : ''}
`;
}
Expand All @@ -163,6 +248,16 @@ ${options.includeValidation ? generateTypescriptValidationCode({model, renderer,
`;
}

// Handle null types (ConstrainedAnyModel with type: null in original schema)
if (isNullModel(model)) {
return `${content}

${renderNullUnmarshal(model)}
${renderNullMarshal(model)}
${options.includeValidation ? generateTypescriptValidationCode({model, renderer, asClassMethods: false, context: context as any}) : ''}
`;
}

return content;
}
}
Expand Down
17 changes: 17 additions & 0 deletions src/codegen/modelina/presets/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function safeStringify(value: any): string {
let depth = 0;
const maxDepth = 255;
const maxRepetitions = 5; // Allow up to 5 repetitions of the same object
let isRoot = true;

// eslint-disable-next-line sonarjs/cognitive-complexity
function stringify(val: any, currentPath: any[] = []): any {
Expand Down Expand Up @@ -50,13 +51,22 @@ export function safeStringify(value: any): string {

depth++;
const newPath = [...currentPath, val];
const atRoot = isRoot;
isRoot = false;

let result: any;

if (Array.isArray(val)) {
result = val.map((item) => stringify(item, newPath));
} else {
result = {};
// Check if this is a root-level schema with oneOf/anyOf that has conflicting type:object
// Modelina adds type:object to union models, but this conflicts with primitive oneOf/anyOf
const hasCompositionKeyword =
val.oneOf !== undefined || val.anyOf !== undefined;
const hasConflictingObjectType =
val.type === 'object' && hasCompositionKeyword;

for (const [key, value] of Object.entries(val)) {
// Skip extension properties
if (
Expand All @@ -68,6 +78,10 @@ export function safeStringify(value: any): string {
) {
continue;
}
// Skip type:object at root level when there's oneOf/anyOf with primitives
if (atRoot && key === 'type' && hasConflictingObjectType) {
continue;
}
// eslint-disable-next-line security/detect-object-injection
result[key] = stringify(value, newPath);
}
Expand Down Expand Up @@ -128,6 +142,9 @@ export function generateTypescriptValidationCode({
return `${schemaProperty} = ${safeStringify(model.originalInput)};
${methodPrefix}validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
const {data, ajvValidatorFunction} = context ?? {};
// Intentionally parse JSON strings to support validation of marshalled output.
// Example: validate({data: marshal(obj)}) works because marshal returns JSON string.
// Note: String 'true' will be coerced to boolean true due to JSON.parse.
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
const validate = ajvValidatorFunction ?? ${createValidatorCall}
return {
Expand Down
9 changes: 3 additions & 6 deletions test/codegen/configurations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,10 @@ describe('configuration manager', () => {
const { config } = await loadAndRealizeConfigFile(CONFIG_YAML);
expect(config.inputType).toEqual('asyncapi');
});
/**
* Cannot run this in this Jest environment, had to manually test it.
*
* TODO
*/
// TypeScript config files require ts-node/tsx for dynamic imports.
// This works at runtime (CLI with tsx), but Jest can't load TS files dynamically.
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should work with correct TS config', async () => {
it.skip('should work with correct TS config (requires ts-node/tsx runtime)', async () => {
const { config } = await loadAndRealizeConfigFile(CONFIG_TS);
expect(config.inputType).toEqual('asyncapi');
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`headers typescript should work with OpenAPI 2.0 inputs 1`] = `
"import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv';
Expand Down Expand Up @@ -39,6 +39,9 @@ class DeletePetHeaders {
public static theCodeGenSchema = {"type":"object","additionalProperties":false,"properties":{"api_key":{"type":"string"}},"$id":"DeletePetHeaders","$schema":"http://json-schema.org/draft-07/schema"};
public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
const {data, ajvValidatorFunction} = context ?? {};
// Intentionally parse JSON strings to support validation of marshalled output.
// Example: validate({data: marshal(obj)}) works because marshal returns JSON string.
// Note: String 'true' will be coerced to boolean true due to JSON.parse.
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
const validate = ajvValidatorFunction ?? this.createValidator(context)
return {
Expand Down Expand Up @@ -97,6 +100,9 @@ class DeletePetHeaders {
public static theCodeGenSchema = {"type":"object","additionalProperties":false,"properties":{"api_key":{"type":"string"}},"$id":"DeletePetHeaders","$schema":"http://json-schema.org/draft-07/schema"};
public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
const {data, ajvValidatorFunction} = context ?? {};
// Intentionally parse JSON strings to support validation of marshalled output.
// Example: validate({data: marshal(obj)}) works because marshal returns JSON string.
// Note: String 'true' will be coerced to boolean true due to JSON.parse.
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
const validate = ajvValidatorFunction ?? this.createValidator(context)
return {
Expand Down Expand Up @@ -155,6 +161,9 @@ class DeletePetHeaders {
public static theCodeGenSchema = {"type":"object","additionalProperties":false,"properties":{"api_key":{"type":"string"}},"$id":"DeletePetHeaders","$schema":"http://json-schema.org/draft-07/schema"};
public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
const {data, ajvValidatorFunction} = context ?? {};
// Intentionally parse JSON strings to support validation of marshalled output.
// Example: validate({data: marshal(obj)}) works because marshal returns JSON string.
// Note: String 'true' will be coerced to boolean true due to JSON.parse.
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
const validate = ajvValidatorFunction ?? this.createValidator(context)
return {
Expand Down Expand Up @@ -247,6 +256,9 @@ class SimpleObjectHeaders {
public static theCodeGenSchema = {"type":"object","properties":{"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}},"$id":"SimpleObjectHeaders","$schema":"http://json-schema.org/draft-07/schema"};
public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
const {data, ajvValidatorFunction} = context ?? {};
// Intentionally parse JSON strings to support validation of marshalled output.
// Example: validate({data: marshal(obj)}) works because marshal returns JSON string.
// Note: String 'true' will be coerced to boolean true due to JSON.parse.
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
const validate = ajvValidatorFunction ?? this.createValidator(context)
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`payloads typescript should not render validation functions 1`] = `
"import {SimpleObject} from './SimpleObject';
Expand Down Expand Up @@ -116,9 +116,12 @@ return payload.marshal();
return JSON.stringify(payload);
}

export const theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","oneOf":[{"type":"object","properties":{"type":{"const":"SimpleObject"},"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}},"$id":"SimpleObject"},{"type":"boolean","$id":"Boolean"},{"type":"string","$id":"String"}],"$id":"UnionPayload"};
export const theCodeGenSchema = {"$schema":"http://json-schema.org/draft-07/schema","oneOf":[{"type":"object","properties":{"type":{"const":"SimpleObject"},"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}},"$id":"SimpleObject"},{"type":"boolean","$id":"Boolean"},{"type":"string","$id":"String"}],"$id":"UnionPayload"};
export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
const {data, ajvValidatorFunction} = context ?? {};
// Intentionally parse JSON strings to support validation of marshalled output.
// Example: validate({data: marshal(obj)}) works because marshal returns JSON string.
// Note: String 'true' will be coerced to boolean true due to JSON.parse.
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
const validate = ajvValidatorFunction ?? createValidator(context)
return {
Expand Down Expand Up @@ -205,6 +208,9 @@ class SimpleObject2 {
public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","properties":{"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}},"$id":"SimpleObject2"};
public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
const {data, ajvValidatorFunction} = context ?? {};
// Intentionally parse JSON strings to support validation of marshalled output.
// Example: validate({data: marshal(obj)}) works because marshal returns JSON string.
// Note: String 'true' will be coerced to boolean true due to JSON.parse.
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
const validate = ajvValidatorFunction ?? this.createValidator(context)
return {
Expand Down Expand Up @@ -297,6 +303,9 @@ class SimpleObject {
public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","properties":{"type":{"const":"SimpleObject"},"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}},"$id":"SimpleObject"};
public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } {
const {data, ajvValidatorFunction} = context ?? {};
// Intentionally parse JSON strings to support validation of marshalled output.
// Example: validate({data: marshal(obj)}) works because marshal returns JSON string.
// Note: String 'true' will be coerced to boolean true due to JSON.parse.
const parsedData = typeof data === 'string' ? JSON.parse(data) : data;
const validate = ajvValidatorFunction ?? this.createValidator(context)
return {
Expand Down
Loading