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
7 changes: 4 additions & 3 deletions docs/generators/payloads.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ The return type is a map of channels and the model that represent the payload.
## Languages
Each language has a set of constraints which means that some typed model types are either supported or not, or it might just be the code generation library that does not yet support it.

| | Circular models | Enums | Tuples | Arrays | Nested Arrays | Dictionaries | Json Serialization |
| | Circular models | Enums | Tuples | Arrays | Nested Arrays | Dictionaries | Json Serialization | Validation |
|---|---|---|---|---|---|---|---|
| **TypeScript** | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |
| **TypeScript** | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ |

### TypeScript

Dependencies: None
Dependencies:
- `ajv`: https://ajv.js.org/guide/getting-started.html ^8.17.1
143 changes: 108 additions & 35 deletions src/codegen/generators/typescript/payloads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ export const zodTypeScriptPayloadGenerator = z.object({
.describe(
'By default we assume that the models might be transpiled to JS, therefore JS restrictions will be applied by default.'
),
includeValidation: z
.boolean()
.optional()
.default(true)
.describe(
'By default we assume that the models will be used to also validate incoming data.'
),
rawPropertyNames: z
.boolean()
.optional()
Expand Down Expand Up @@ -162,6 +169,83 @@ return ${model.type}.unmarshal(json);
}
return {};
}
function renderUnionMarshal(model: ConstrainedUnionModel) {
const unmarshalChecks = model.union.map((unionModel) => {
if (
unionModel instanceof ConstrainedReferenceModel &&
unionModel.ref instanceof ConstrainedObjectModel
) {
return `if(payload instanceof ${unionModel.type}) {
return payload.marshal();
}`;
}
});
return `export function marshal(payload: ${model.name}) {
${unmarshalChecks.join('\n')}
return JSON.stringify(payload);
}`;
}
function renderUnionUnmarshal(model: ConstrainedUnionModel, renderer: TypeScriptRenderer) {
const discriminatorChecks = model.union.map((model) => {
return findDiscriminatorChecks(model, renderer);
});
const hasObjValues =
discriminatorChecks.filter((value) => value?.objCheck).length >=
1;
return `export function unmarshal(json: any): ${model.name} {
${
hasObjValues
? `if(typeof json === 'object') {
${discriminatorChecks
.filter((value) => value?.objCheck)
.map((value) => value?.objCheck)
.join('\n ')}
}`
: ''
}
return JSON.parse(json);
}`;
}

/**
* Safe stringify that removes x- properties and circular references by assuming true
*/
export function safeStringify (value: any): string {
const stack: any[] = [];
let r = 0;
const replacer = (key: string, value: any) => {
// remove extension properties
if (key.startsWith('x-')) { return; }

switch (typeof value) {
case "function":
return 'true';
// is this a primitive value ?
case "boolean":
case "number":
case "string":
// primitives cannot have properties
// so these are safe to parse
return value;
default: {
// only null does not need to be stored
// for all objects check recursion first
// hopefully 255 calls are enough ...
if (!value || 255 < ++r) {return 'true';}

const i = stack.indexOf(value);
// all objects not already parsed
if (i < 0) {return stack.push(value) && value;}
// all others are duplicated or cyclic
// let them through
return 'true';
}
}
};

return JSON.stringify(value, replacer);
}

// eslint-disable-next-line sonarjs/cognitive-complexity
export async function generateTypescriptPayload(
context: TypeScriptPayloadContext
Expand All @@ -180,45 +264,34 @@ export async function generateTypescriptPayload(
marshalling: true
}
},
{
class: {
additionalContent: ({content, model, renderer}) => {
if (!generator.includeValidation) {
return content;
}
renderer.dependencyManager.addTypeScriptDependency('{Ajv, Options as AjvOptions, ValidateFunction}', 'ajv');
renderer.dependencyManager.addTypeScriptDependency('addFormats', 'ajv-formats');
return `${content}
public theCodeGenSchema = ${safeStringify(model.originalInput)};
public validate(context : {data: any, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; validateFunction: ValidateFunction; } {
const {ajvInstance, data} = {...context, ajvInstance: new Ajv(context.ajvOptions ?? {})};
addFormats(ajvInstance);
const validate = ajvInstance.compile(this.theCodeGenSchema);
return {valid: validate(data), validateFunction: validate};
}
`;
}
}
},
{
type: {
self({model, content, renderer}) {
if (model instanceof ConstrainedUnionModel) {
const discriminatorChecks = model.union.map((model) => {
return findDiscriminatorChecks(model, renderer);
});
const unmarshalChecks = model.union.map((unionModel) => {
if (
unionModel instanceof ConstrainedReferenceModel &&
unionModel.ref instanceof ConstrainedObjectModel
) {
return `if(payload instanceof ${unionModel.type}) {
return payload.marshal();
}`;
}
});
const hasObjValues =
discriminatorChecks.filter((value) => value?.objCheck).length >=
1;
return `${content}\n

export function unmarshal(json: any): ${model.name} {
${
hasObjValues
? `if(typeof json === 'object') {
${discriminatorChecks
.filter((value) => value?.objCheck)
.map((value) => value?.objCheck)
.join('\n ')}
}`
: ''
}
return JSON.parse(json);
}
export function marshal(payload: ${model.name}) {
${unmarshalChecks.join('\n')}
return JSON.stringify(payload);
}`;
return `${content}

${renderUnionUnmarshal(model, renderer)}
${renderUnionMarshal(model)}`;
}
return content;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,98 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`payloads typescript should work with basic AsyncAPI inputs 1`] = `
exports[`payloads typescript should not render validation functions 1`] = `
"import {SimpleObject} from './SimpleObject';
type UnionPayload = SimpleObject | boolean | string;

export function unmarshal(json: any): UnionPayload {
if(typeof json === 'object') {
if(json.type === 'SimpleObject') {
return SimpleObject.unmarshal(json);
}
}
return JSON.parse(json);
}
export function marshal(payload: UnionPayload) {
if(payload instanceof SimpleObject) {
return payload.marshal();
}


return JSON.stringify(payload);
}
export { UnionPayload };"
`;

exports[`payloads typescript should not render validation functions 2`] = `
"
class SimpleObject2 {
private _displayName?: string;
private _email?: string;
private _additionalProperties?: Record<string, any>;

constructor(input: {
displayName?: string,
email?: string,
additionalProperties?: Record<string, any>,
}) {
this._displayName = input.displayName;
this._email = input.email;
this._additionalProperties = input.additionalProperties;
}

get displayName(): string | undefined { return this._displayName; }
set displayName(displayName: string | undefined) { this._displayName = displayName; }

get email(): string | undefined { return this._email; }
set email(email: string | undefined) { this._email = email; }

get additionalProperties(): Record<string, any> | undefined { return this._additionalProperties; }
set additionalProperties(additionalProperties: Record<string, any> | undefined) { this._additionalProperties = additionalProperties; }

public marshal() : string {
let json = '{'
if(this.displayName !== undefined) {
json += \`"displayName": \${typeof this.displayName === 'number' || typeof this.displayName === 'boolean' ? this.displayName : JSON.stringify(this.displayName)},\`;
}
if(this.email !== undefined) {
json += \`"email": \${typeof this.email === 'number' || typeof this.email === 'boolean' ? this.email : JSON.stringify(this.email)},\`;
}
if(this.additionalProperties !== undefined) {
for (const [key, value] of this.additionalProperties.entries()) {
//Only unwrap those that are not already a property in the JSON object
if(["displayName","email","additionalProperties"].includes(String(key))) continue;
json += \`"\${key}": \${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},\`;
}
}
//Remove potential last comma
return \`\${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}\`;
}

public static unmarshal(json: string | object): SimpleObject2 {
const obj = typeof json === "object" ? json : JSON.parse(json);
const instance = new SimpleObject2({} as any);

if (obj["displayName"] !== undefined) {
instance.displayName = obj["displayName"];
}
if (obj["email"] !== undefined) {
instance.email = obj["email"];
}

instance.additionalProperties = new Map();
const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["displayName","email","additionalProperties"].includes(key);}));
for (const [key, value] of propsToCheck) {
instance.additionalProperties.set(key, value as any);
}
return instance;
}
}
export { SimpleObject2 };"
`;

exports[`payloads typescript should work with basic AsyncAPI inputs 1`] = `
"import {SimpleObject} from './SimpleObject';
type UnionPayload = SimpleObject | boolean | string;

export function unmarshal(json: any): UnionPayload {
if(typeof json === 'object') {
Expand All @@ -15,7 +104,7 @@ export function unmarshal(json: any): UnionPayload {
}
export function marshal(payload: UnionPayload) {
if(payload instanceof SimpleObject) {
return payload.marshal();
return payload.marshal();
}


Expand All @@ -25,7 +114,8 @@ export { UnionPayload };"
`;

exports[`payloads typescript should work with basic AsyncAPI inputs 2`] = `
"
"import {Ajv, Options as AjvOptions, ValidateFunction} from 'ajv';
import addFormats from 'ajv-formats';
class SimpleObject2 {
private _displayName?: string;
private _email?: string;
Expand Down Expand Up @@ -87,12 +177,21 @@ class SimpleObject2 {
}
return instance;
}
public 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 validate(context : {data: any, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; validateFunction: ValidateFunction; } {
const {ajvInstance, data} = {...context, ajvInstance: new Ajv(context.ajvOptions ?? {})};
addFormats(ajvInstance);
const validate = ajvInstance.compile(this.theCodeGenSchema);
return {valid: validate(data), validateFunction: validate};
}

}
export { SimpleObject2 };"
`;

exports[`payloads typescript should work with no channels 1`] = `
"
"import {Ajv, Options as AjvOptions, ValidateFunction} from 'ajv';
import addFormats from 'ajv-formats';
class AnonymousSchema_1 {
private _type?: 'SimpleObject' = 'SimpleObject';
private _displayName?: string;
Expand Down Expand Up @@ -160,6 +259,14 @@ class AnonymousSchema_1 {
}
return instance;
}
public theCodeGenSchema = {"type":"object","properties":{"type":{"const":"SimpleObject"},"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}}};
public validate(context : {data: any, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; validateFunction: ValidateFunction; } {
const {ajvInstance, data} = {...context, ajvInstance: new Ajv(context.ajvOptions ?? {})};
addFormats(ajvInstance);
const validate = ajvInstance.compile(this.theCodeGenSchema);
return {valid: validate(data), validateFunction: validate};
}

}
export { AnonymousSchema_1 };"
`;
Loading