From c1e823f2751e0b30df6d857d4f79279cdcee24c5 Mon Sep 17 00:00:00 2001 From: Benjamin Eckel Date: Fri, 25 Oct 2024 15:27:07 -0500 Subject: [PATCH] Refactor Bindgen https://github.com/dylibso/xtp-bindgen/pull/18 --- package-lock.json | 25 +++++-- package.json | 2 +- src/index.ts | 135 ++++++++++++++++++++++++-------------- template/src/index.ts.ejs | 12 ++-- template/src/pdk.ts.ejs | 62 ++++++++--------- tests/schemas/fruit.yaml | 30 +++++++++ 6 files changed, 176 insertions(+), 90 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2abd3d3..09f1773 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.0.15", "license": "BSD-3-Clause", "dependencies": { - "@dylibso/xtp-bindgen": "1.0.0-rc.11", + "@dylibso/xtp-bindgen": "file:///Users/ben/dylibso/xtp/xtp-bindgen", "ejs": "^3.1.10" }, "devDependencies": { @@ -20,10 +20,27 @@ "typescript": "^5.3.2" } }, - "node_modules/@dylibso/xtp-bindgen": { + "../xtp-bindgen": { + "name": "@dylibso/xtp-bindgen", "version": "1.0.0-rc.11", - "resolved": "https://registry.npmjs.org/@dylibso/xtp-bindgen/-/xtp-bindgen-1.0.0-rc.11.tgz", - "integrity": "sha512-zXesPfNHKaEK3IwMKFW5qk4UoJTazxmslpyUYn6n4FffZvY7QPBOSomyNVaNRtTr3ziz5SwF2RAm+Rken21HIg==" + "license": "BSD-3-Clause", + "devDependencies": { + "@extism/js-pdk": "^1.0.1", + "@types/jest": "^29.5.12", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.8.1", + "const": "^1.0.0", + "esbuild": "^0.17.0", + "esbuild-plugin-d.ts": "^1.2.3", + "jest": "^29.0.0", + "js-yaml": "^4.1.0", + "ts-jest": "^29.0.0", + "typescript": "^5.6.3" + } + }, + "node_modules/@dylibso/xtp-bindgen": { + "resolved": "../xtp-bindgen", + "link": true }, "node_modules/@esbuild/aix-ppc64": { "version": "0.19.12", diff --git a/package.json b/package.json index a0ae529..ce14e5b 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "typescript": "^5.3.2" }, "dependencies": { - "@dylibso/xtp-bindgen": "1.0.0-rc.11", + "@dylibso/xtp-bindgen": "file:///Users/ben/dylibso/xtp/xtp-bindgen", "ejs": "^3.1.10" } } diff --git a/src/index.ts b/src/index.ts index 3c65f66..5190ec6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,57 +1,93 @@ import ejs from 'ejs' -import { helpers, getContext, Property, Parameter } from "@dylibso/xtp-bindgen" +import { helpers, getContext, ObjectType, EnumType, ArrayType, XtpNormalizedType, MapType, Parameter, Property } from "@dylibso/xtp-bindgen" -function toTypeScriptType(property: Property | Parameter): string { - let tp - if (property.$ref) { - tp = property.$ref.name - } else { - switch (property.type) { - case "string": - if (property.format === 'date-time') { - tp = 'Date' - } else { - tp = "string" - } - break - case "integer": - if (property.format === 'int64') { - throw Error(`We do not support format int64 yet`) - } else { - tp = "number" - } - break - case "number": - tp = "number" - break - case "boolean": - tp = "boolean" - break - case "object": - tp = "any" - break - case "array": - if (!property.items) { - tp = 'Array' - } else { - // TODO this is not quite right to force cast - tp = `Array<${toTypeScriptType(property.items as Property)}>` - } - break - case "buffer": - tp = "ArrayBufferLike" - break - } +function toTypeScriptTypeX(type: XtpNormalizedType): string { + // annotate with null if nullable + const nullify = (t: string) => `${t}${type.nullable ? ' | null' : ''}` + + switch (type.kind) { + case 'string': + return nullify('string') + case 'int32': + case 'float': + case 'double': + case 'byte': + return nullify('number') + case 'date-time': + return nullify('Date') + case 'boolean': + return nullify('boolean') + case 'array': + const arrayType = type as ArrayType + return nullify(toTypeScriptTypeX(arrayType.elementType)) + case 'buffer': + return nullify('ArrayBufferLike') + case 'object': + return nullify((type as ObjectType).name) + case 'enum': + return nullify((type as EnumType).name) + case 'map': + const { keyType, valueType } = type as MapType + return nullify(`Record<${toTypeScriptTypeX(keyType)}, ${toTypeScriptTypeX(valueType)}>`) + case 'int64': + throw Error(`We do not support format int64 yet`) + default: + throw new Error("Cant convert property to typescript type: " + JSON.stringify(type)) + } +} + +type XtpTyped = { xtpType: XtpNormalizedType } + +function toTypeScriptType(property: XtpTyped): string { + return toTypeScriptTypeX(property.xtpType!) +} + +/** + * Derives the name of the function responsible for casting the type + */ +function castingFunction(t: XtpNormalizedType, direction: 'From' | 'To'): string { + switch (t.kind) { + case 'object': + return `${(t as ObjectType).name}.${direction.toLowerCase()}Json` + case 'array': + return castingFunction((t as ArrayType).elementType, direction) + case 'map': + return castingFunction((t as MapType).valueType, direction) + case 'date-time': + return `date${direction}Json` + case 'buffer': + return `buffer${direction}Json` + default: + throw new Error(`Type not meant to be casted ${JSON.stringify(t)}`) } +} - if (!tp) throw new Error("Cant convert property to typescript type: " + property.type) - if (!property.nullable) return tp - return `${tp} | null` +/** + * Check whether this type needs to be cast or not + */ +function isCastable(t: XtpNormalizedType): boolean { + if (['object', 'date-time', 'buffer'].includes(t.kind)) return true + + switch (t.kind) { + case 'array': + return isCastable((t as ArrayType).elementType) + case 'map': + return isCastable((t as MapType).valueType) + default: + return false + } } -// TODO: can move this helper up to shared library? -function isBuffer(property: Property | Parameter): boolean { - return property.type === 'buffer' +/** + * Renders the function call to cast the value + * Assumes the target is called `obj` + * + * Example: cast(dateFromJson, obj.myDateValue) + */ +function castExpression(t: XtpNormalizedType, propName: string, direction: 'From' | 'To'): string { + let cast = 'cast' + if (['array', 'map'].includes(t.kind)) cast = 'castValues' + return `${cast}(${castingFunction(t, direction)}, obj.${propName})` } export function render() { @@ -59,7 +95,8 @@ export function render() { const ctx = { ...getContext(), ...helpers, - isBuffer, + isCastable, + castExpression, toTypeScriptType, } const output = ejs.render(tmpl, ctx) diff --git a/template/src/index.ts.ejs b/template/src/index.ts.ejs index ee39b55..06b7160 100644 --- a/template/src/index.ts.ejs +++ b/template/src/index.ts.ejs @@ -13,11 +13,11 @@ export function <%- ex.name %>(): number { <% if (isJsonEncoded(ex.input)) { -%> <% if (isBuffer(ex.input)) { -%> const input: <%- toTypeScriptType(ex.input) %> = Host.base64ToArrayBuffer(JSON.parse(Host.inputString())) - <% } else if (isPrimitive(ex.input)) { -%> - const input: <%- toTypeScriptType(ex.input) %> = JSON.parse(Host.inputString()) - <% } else { -%> + <% } else if (isObject(ex.input)) { -%> const untypedInput = JSON.parse(Host.inputString()) const input = <%- toTypeScriptType(ex.input) %>.fromJson(untypedInput) + <% } else { -%> + const input: <%- toTypeScriptType(ex.input) %> = JSON.parse(Host.inputString()) <% } -%> <% } else if (ex.input.type === 'string') { -%> const input = Host.inputString() <%- (ex.input.$ref && ex.input.$ref.enum) ? `as ${ex.input.$ref.name}` : "" %> @@ -42,11 +42,11 @@ export function <%- ex.name %>(): number { <% if (isJsonEncoded(ex.output)) { -%> <% if (isBuffer(ex.output)) { -%> Host.outputString(JSON.stringify(Host.arrayBufferToBase64(output))) - <% } else if (isPrimitive(ex.output)) { -%> - Host.outputString(JSON.stringify(output)) - <% } else { -%> + <% } else if (isObject(ex.output)) { -%> const untypedOutput = <%- toTypeScriptType(ex.output) %>.toJson(output) Host.outputString(JSON.stringify(untypedOutput)) + <% } else { -%> + Host.outputString(JSON.stringify(output)) <% } -%> <% } else if (ex.output.type === 'string') { -%> Host.outputString(output) diff --git a/template/src/pdk.ts.ejs b/template/src/pdk.ts.ejs index d527301..5c33cbe 100644 --- a/template/src/pdk.ts.ejs +++ b/template/src/pdk.ts.ejs @@ -8,10 +8,24 @@ function isNull(v: any): boolean { function cast(caster: (v: any) => any, v: any): any { if (isNull(v)) return v - if (Array.isArray(v)) return v.map(caster) return caster(v) } +function castValues(caster: (v: any) => any, v: any): any { + if (isNull(v)) return v + caster = cast.bind(null, caster) // bind to null preserving logic in `cast` + + // if it's an array just map it + if (Array.isArray(v)) return v.map(caster) + + // if it's not an array let's assume it's a map + const newMap: any = {} + for (const k in v) { + newMap[k] = caster(v[k]) + } + return newMap +} + function dateToJson(v: Date): string { return v.toISOString() } @@ -40,22 +54,16 @@ export class <%- schema.name %> { * <%- formatCommentBlock(p.description) %> */ <% } -%> - <%- (!p.required || toTypeScriptType(p) === 'any') ? null : '// @ts-expect-error TS2564\n' -%> - <%- p.name %><%- !p.required ? '?' : null %>: <%- toTypeScriptType(p) %>; + <%- (!p.xtpType.required || toTypeScriptType(p) === 'any') ? null : '// @ts-expect-error TS2564\n' -%> + <%- p.name %><%- !p.xtpType.required ? '?' : null %>: <%- toTypeScriptType(p) %>; <% }) %> static fromJson(obj: any): <%- schema.name %> { return { ...obj, <% schema.properties.forEach(p => { -%> - <% let baseP = p.items ? p.items : p -%> - <% let baseRef = p.$ref ? p.$ref.name : (p.items && p.items.$ref ? p.items.$ref.name : null) -%> - <% if (isDateTime(baseP)) { -%> - <%- p.name -%>: cast(dateFromJson, obj.<%- p.name -%>), - <% } else if (isBuffer(baseP)) {-%> - <%- p.name -%>: cast(bufferFromJson, obj.<%- p.name -%>), - <% } else if (!isPrimitive(baseP)) {-%> - <%- p.name -%>: cast(<%- baseRef -%>.fromJson, obj.<%- p.name -%>), + <% if (isCastable(p.xtpType)) { -%> + <%- p.name -%>: <%- castExpression(p.xtpType, p.name, 'From') %>, <% } -%> <% }) -%> } @@ -65,20 +73,14 @@ export class <%- schema.name %> { return { ...obj, <% schema.properties.forEach(p => { -%> - <% let baseP = p.items ? p.items : p -%> - <% let baseRef = p.$ref ? p.$ref.name : (p.items && p.items.$ref ? p.items.$ref.name : null) -%> - <% if (isDateTime(baseP)) { -%> - <%- p.name -%>: cast(dateToJson, obj.<%- p.name -%>), - <% } else if (isBuffer(baseP)) {-%> - <%- p.name -%>: cast(bufferToJson, obj.<%- p.name -%>), - <% } else if (!isPrimitive(baseP)) {-%> - <%- p.name -%>: cast(<%- baseRef -%>.toJson, obj.<%- p.name -%>), + <% if (isCastable(p.xtpType)) { -%> + <%- p.name -%>: <%- castExpression(p.xtpType, p.name, 'To') %>, <% } -%> <% }) -%> } } } - <% } else if (schema.enum) { %> + <% } else if (isEnum(schema)) { %> /** * <%- formatCommentLine(schema.description) %> @@ -115,15 +117,15 @@ export enum <%- schema.name %> { export function <%- imp.name %>(<%- imp.input ? `input: ${toTypeScriptType(imp.input)}` : null %>) <%- imp.output ? `:${toTypeScriptType(imp.output)}` : null %> { <% if (imp.input) { -%> <% if (isJsonEncoded(imp.input)) { -%> - <% if (isPrimitive(imp.input)) { %> - const mem = Memory.fromJsonObject(input as any) - <% } else { %> - const casted = <%- toTypeScriptType(imp.input) %>.toJson(input) + <% if (isObject(imp.input)) { %> + const casted = <%- castingFunction(imp.input.xtpType, 'From') %>(input) const mem = Memory.fromJsonObject(casted) + <% } else { %> + const mem = Memory.fromJsonObject(input as any) <% } %> <% } else if (isUtf8Encoded(imp.input)) { -%> const mem = Memory.fromString(input as string) - <% } else if (imp.input.type === 'string') { -%> + <% } else if (isString(imp.input.type)) { -%> const mem = Memory.fromString(input) <% } else { -%> const mem = Memory.fromBuffer(input) @@ -136,15 +138,15 @@ export function <%- imp.name %>(<%- imp.input ? `input: ${toTypeScriptType(imp.i <% if (imp.output) { -%> <% if (isJsonEncoded(imp.output)) { -%> - <% if (isPrimitive(imp.output)) { -%> - return Memory.find(ptr).readJsonObject(); - <% } else { -%> + <% if (isObject(imp.output)) { -%> const output = Memory.find(ptr).readJsonObject(); - return <%- toTypeScriptType(imp.output) %>.fromJson(output) + return <%- castingFunction(imp.output.xtpType, 'To') %>(output) + <% } else { -%> + return Memory.find(ptr).readJsonObject(); <% } -%> <% } else if (isUtf8Encoded(imp.output)) { -%> return Memory.find(ptr).readString(); - <% } else if (imp.output.type === 'string') { -%> + <% } else if (isString(imp.output)) { -%> return Memory.find(ptr).readString(); <% } else { -%> return Memory.find(ptr).readBytes(); diff --git a/tests/schemas/fruit.yaml b/tests/schemas/fruit.yaml index 27c17d6..b3e8cab 100644 --- a/tests/schemas/fruit.yaml +++ b/tests/schemas/fruit.yaml @@ -109,12 +109,22 @@ components: - clyde description: A set of all the enemies of pac-man ComplexObject: + required: + - arrayOfDate properties: arrayOfDate: type: array items: type: string format: date-time + arrayOfEnum: + type: array + items: + "$ref": "#/components/schemas/GhostGang" + arrayOfObjects: + type: array + items: + "$ref": "#/components/schemas/WriteParams" ghost: "$ref": "#/components/schemas/GhostGang" description: I can override the description for the property here @@ -138,4 +148,24 @@ components: writeParams: "$ref": "#/components/schemas/WriteParams" nullable: true + aStringMap: + type: string + additionalProperties: + type: string + aWriteParamMap: + type: string + additionalProperties: + "$ref": "#/components/schemas/WriteParams" + aNullableWriteParamMap: + type: string + nullable: true + additionalProperties: + "$ref": "#/components/schemas/WriteParams" + aBuffer: + type: buffer + nullable: true + aMap: + description: a string map + additionalProperties: + type: string description: A complex json object