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
131 changes: 120 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isLogicalType,
isMapType,
isRecordType,
NameOrType,
RecordType,
Schema,
Type,
Expand Down Expand Up @@ -49,16 +50,21 @@ export function convertPrimitive(avroType: string): string {
export function avroToTypeScript(schema: Schema, opts: ConversionOptions = {}): string {
const output: string[] = [];
if (isEnumType(schema)) convertEnum(schema, output);
else if (isRecordType(schema)) convertRecord(schema, output, opts);
else if (isRecordType(schema)) convertRecord(schema, output, opts, schema.namespace);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It might make sense at this point to introduce a "parse context" kind of object to hold output, opts, and namespace instead of passing three variables around everywhere. I didn't do this to keep this PR focused on the new feature.

else throw "Unknown top level type " + (schema as unknown)["type"];
return output.join("\n");
}

/** Convert an Avro Record type. Return the name, but add the definition to the file */
export function convertRecord(recordType: RecordType, fileBuffer: string[], opts: ConversionOptions): string {
export function convertRecord(
recordType: RecordType,
fileBuffer: string[],
opts: ConversionOptions,
namespace: string | undefined,
): string {
let buffer = `export interface ${recordType.name} {\n`;
for (let field of recordType.fields) {
buffer += convertFieldDec(field, fileBuffer, opts) + "\n";
buffer += convertFieldDec(field, fileBuffer, opts, namespace) + "\n";
}
buffer += "}\n";
fileBuffer.push(buffer);
Expand All @@ -72,31 +78,36 @@ export function convertEnum(enumType: EnumType, fileBuffer: string[]): string {
return enumType.name;
}

export function convertType(type: Type, buffer: string[], opts: ConversionOptions): string {
export function convertType(
type: Type,
buffer: string[],
opts: ConversionOptions,
namespace: string | undefined,
): string {
// if it's just a name, then use that
if (typeof type === "string") {
return convertPrimitive(type) || type;
} else if (type instanceof Array) {
// array means a Union. Use the names and call recursively
return type.map((t) => convertType(t, buffer, opts)).join(" | ");
return convertUnion(type, buffer, opts, namespace);
} else if (isRecordType(type)) {
//} type)) {
// record, use the name and add to the buffer
return convertRecord(type, buffer, opts);
return convertRecord(type, buffer, opts, namespace);
} else if (isArrayType(type)) {
// array, call recursively for the array element type
return convertType(type.items, buffer, opts) + "[]";
return convertType(type.items, buffer, opts, namespace) + "[]";
} else if (isMapType(type)) {
// Dictionary of types, string as key
return `{ [index:string]:${convertType(type.values, buffer, opts)} }`;
return `{ [index:string]:${convertType(type.values, buffer, opts, namespace)} }`;
} else if (isEnumType(type)) {
// array, call recursively for the array element type
return convertEnum(type, buffer);
} else if (isLogicalType(type)) {
if (opts.logicalTypes && opts.logicalTypes[type.logicalType]) {
return opts.logicalTypes[type.logicalType];
}
return convertType(type.type, buffer, opts);
return convertType(type.type, buffer, opts, namespace);
} else if (isFixedType(type)) {
return "Uint8Array";
} else {
Expand All @@ -105,7 +116,105 @@ export function convertType(type: Type, buffer: string[], opts: ConversionOption
}
}

export function convertFieldDec(field: Field, buffer: string[], opts: ConversionOptions): string {
let typeName = convertType(field.type, buffer, opts);
export function convertUnion(
type: NameOrType[],
buffer: string[],
opts: ConversionOptions,
namespace: string | undefined,
): string {
// Wrapped unions is all-or-nothing, even if some values are unambiguous within the union.
// For example, with "int" | "double" | "string", the numbers are ambigous but the string
// should be unambiguous. Nevertheless, at least with avsc it treats ambiguity as
// all-or-nothing.
if (shouldWrapUnionMembers(type, opts)) {
return type
.map((t) => {
// Null is a special case. Even when using wrapped unions, plain null is allowed
// as a value in addition to {"null": null}.
const nullPrefix = t === "null" ? "null | " : "";
return `${nullPrefix}{ "${keyForUnionElement(t, namespace)}": ${convertType(t, buffer, opts, namespace)} }`;
})
.join(" | ");
} else {
return type.map((t) => convertType(t, buffer, opts, namespace)).join(" | ");
}
}

function shouldWrapUnionMembers(type: NameOrType[], opts: ConversionOptions): boolean {
switch (opts.wrapUnions) {
case "always":
return true;
case "never":
return false;
case "auto": {
return hasAmbiguousUnionMembers(type);
}
default:
return opts.wrapUnions ?? false;
}
}

function hasAmbiguousUnionMembers(type: NameOrType[]): boolean {
// If there is more than one union member that shares any JS type, it'll be considered
// ambiguous and must be wrapped.
const result: Record<string, boolean> = {};
for (const t of type) {
const typeName = jsTypeForAvroType(t);
if (result[typeName]) {
return true;
}
result[typeName] = true;
}
return false;
}

function jsTypeForAvroType(t: NameOrType): string {
const avroType = typeof t === "string" ? t : t.type;
switch (avroType) {
case "long":
case "int":
case "double":
case "float":
return "number";
case "bytes":
case "fixed":
return "buffer";
case "null":
return "null";
case "boolean":
return "boolean";
case "string":
case "enum":
return "string";
case "record":
case "map":
return "object";
case "array":
return "array";
default:
return avroType;
}
}

export function keyForUnionElement(type: NameOrType, namespace: string | undefined): string {
if (typeof type === "string") {
return type;
}
if (isRecordType(type) || isFixedType(type) || isEnumType(type)) {
if (namespace) {
return `${namespace}.${type.name}`;
}
return type.name;
}
return type.type;
}

export function convertFieldDec(
field: Field,
buffer: string[],
opts: ConversionOptions,
namespace: string | undefined,
): string {
let typeName = convertType(field.type, buffer, opts, namespace);
return `\t${field.name}: ${typeName};`;
}
2 changes: 2 additions & 0 deletions src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
export type Schema = RecordType | EnumType;
export interface ConversionOptions {
logicalTypes?: { [type: string]: string };
wrapUnions?: boolean | "always" | "never" | "auto";

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This allows boolean to match the corresponding flag in avsc, so that code that uses both avro-typescript and avsc can use the same definition of the flag. It could also use a tighter definition without the boolean: wrapUnions?: "always" | "never" | "auto"; or even wrapUnions?: "always" | "never" | "avsc-auto"; to make it clear that auto is targeting avsc's behavior.

}

export type Type = NameOrType | NameOrType[];
Expand All @@ -22,6 +23,7 @@ export interface BaseType {
export interface RecordType extends BaseType {
type: "record";
name: string;
namespace?: string;
fields: Field[];
}

Expand Down
84 changes: 83 additions & 1 deletion test/__snapshots__/avtsc.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing

exports[`avroToTypeScript it should generate an interface 1`] = `
"export enum numberEnumType { ONE = 'ONE', TWO = 'TWO' };
Expand All @@ -9,20 +9,102 @@ export interface AllocsType {
allocQty: null | number;
}

export interface Option1 {
id: string;
refKey: string;
}

export interface Option2 {
id: string;
orderNumber: string;
quantity: number;
}

export interface ExampleType {
unionEnum: null | string | numberEnumType;
mandatoryString: string;
adouble: null | number;
astring: null | string;
amap: null | string | { [index:string]:numberEnumType };
recordArray: null | AllocsType[];
unionRecord: null | Option1 | Option2;
processCode: null | string;
named: null | AllocsType;
fixedField: Uint8Array;
}
"
`;

exports[`avroToTypeScript it should generate an interface with auto-wrapped unions 1`] = `
"export enum numberEnumType { ONE = 'ONE', TWO = 'TWO' };

export interface AllocsType {
allocAccount: null | string;
noNestedPartyIDs: null | number;
allocQty: null | number;
}

export interface Option1 {
id: string;
refKey: string;
}

export interface Option2 {
id: string;
orderNumber: string;
quantity: number;
}

export interface ExampleType {
unionEnum: null | { "null": null } | { "string": string } | { "avro-typescript.numberEnumType": numberEnumType };
mandatoryString: string;
adouble: null | number;
astring: null | string;
amap: null | string | { [index:string]:numberEnumType };
recordArray: null | AllocsType[];
unionRecord: null | { "null": null } | { "avro-typescript.Option1": Option1 } | { "avro-typescript.Option2": Option2 };
processCode: null | string;
named: null | AllocsType;
fixedField: Uint8Array;
}
"
`;

exports[`avroToTypeScript it should generate an interface with wrapped unions 1`] = `
"export enum numberEnumType { ONE = 'ONE', TWO = 'TWO' };

export interface AllocsType {
allocAccount: null | { "null": null } | { "string": string };
noNestedPartyIDs: null | { "null": null } | { "long": number };
allocQty: null | { "null": null } | { "double": number };
}

export interface Option1 {
id: string;
refKey: string;
}

export interface Option2 {
id: string;
orderNumber: string;
quantity: number;
}

export interface ExampleType {
unionEnum: null | { "null": null } | { "string": string } | { "avro-typescript.numberEnumType": numberEnumType };
mandatoryString: string;
adouble: null | { "null": null } | { "double": number };
astring: null | { "null": null } | { "string": string };
amap: null | { "null": null } | { "string": string } | { "map": { [index:string]:numberEnumType } };
recordArray: null | { "null": null } | { "array": AllocsType[] };
unionRecord: null | { "null": null } | { "avro-typescript.Option1": Option1 } | { "avro-typescript.Option2": Option2 };
processCode: null | { "null": null } | { "string": string };
named: null | { "null": null } | { "AllocsType": AllocsType };
fixedField: Uint8Array;
}
"
`;

exports[`avroToTypeScript it should support overriding logical types 1`] = `
"export interface logicalOverrides {
eventDate: Date;
Expand Down
12 changes: 12 additions & 0 deletions test/avtsc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ describe("avroToTypeScript", () => {
expect(avroToTypeScript(schema as RecordType)).toMatchSnapshot();
});

test("it should generate an interface with wrapped unions", () => {
const schemaText = fs.readFileSync(__dirname + "/example.avsc", "utf-8");
const schema = JSON.parse(schemaText);
expect(avroToTypeScript(schema as RecordType, { wrapUnions: true })).toMatchSnapshot();
});

test("it should generate an interface with auto-wrapped unions", () => {
const schemaText = fs.readFileSync(__dirname + "/example.avsc", "utf-8");
const schema = JSON.parse(schemaText);
expect(avroToTypeScript(schema as RecordType, { wrapUnions: "auto" })).toMatchSnapshot();
});

test("it should correctly type strings with logicalType", () => {
const schema: Schema = {
type: "record",
Expand Down
25 changes: 25 additions & 0 deletions test/example.avsc
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"type": "record",
"namespace": "avro-typescript",
"name": "ExampleType",
"fields": [
{
Expand Down Expand Up @@ -53,6 +54,30 @@
}
]
},
{
"name": "unionRecord",
"default": null,
"type": [
"null",
{
"type": "record",
"name": "Option1",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "refKey", "type": "string" }
]
},
{
"type": "record",
"name": "Option2",
"fields": [
{ "name": "id", "type": "string" },
{ "name": "orderNumber", "type": "string" },
{ "name": "quantity", "type": "long" }
]
}
]
},
{ "name": "processCode", "default": null, "type": ["null", "string"] },
{ "name": "named", "default": null, "type": ["null", "AllocsType"] },
{
Expand Down