UNPKG

smartsuite-typescript-api

Version:

Typescript type generator and wrapper for the REST API provided by SmartSuite. Currently in pre 1.0 so no semver guarantees are given

488 lines (487 loc) 21.6 kB
import * as z from "zod"; import { printNode, zodToTs } from 'zod-to-ts'; import { Project, SyntaxKind, ts, Node } from "ts-morph"; import fs from "node:fs"; import path from "node:path"; export function addToTypeMap(tableID, tableName, typeMapperName, typeDirPath) { const project = new Project({ tsConfigFilePath: path.join(typeDirPath, "tsconfig.json") }); let typeMapSourceFile = project.addSourceFileAtPath(path.join(typeDirPath, "typeMap.ts")); // Ensure import exists const importPath = "./" + typeMapperName.replace("Mapper", "") + ".js"; const existingImport = typeMapSourceFile .getImportDeclarations() .find(imp => imp.getModuleSpecifierValue() === importPath); if (!existingImport) { typeMapSourceFile.addImportDeclaration({ namedImports: [typeMapperName], moduleSpecifier: importPath, }); } else { const hasNamedImport = existingImport.getNamedImports().some(n => n.getName() === typeMapperName); if (!hasNamedImport) { existingImport.addNamedImport({ name: typeMapperName }); } } let typeMap = typeMapSourceFile.getVariableDeclaration("typeMap"); typeMap.removeType(); const initializer = typeMap.getInitializerIfKindOrThrow(SyntaxKind.ObjectLiteralExpression); // Instead of checking if any props(id, name or mapper) have changed we just // force a full regen by deleting and re-crafting const keysToRemove = new Set([tableID, tableName]); initializer .getProperties() .filter(Node.isPropertyAssignment) .filter(p => keysToRemove.has(p.getName().replace(/"/g, ""))) .forEach(p => p.remove()); const tableTypingTools = `{tableID: "${tableID}", tableName: "${tableName}", mapper: new ${typeMapperName}()}`; initializer.addPropertyAssignments([ { name: `"${tableID}"`, initializer: tableTypingTools, }, { name: `"${tableName}"`, initializer: tableTypingTools, } ]); typeMapSourceFile.saveSync(); typeMapSourceFile.emitSync(); } // A nice little runtime trick to test for valid syntax using the actual compiler // Also, one function call away from being a future CTF task... // Inspiration from Mathias Bynens (https://github.com/mathiasbynens/mothereff.in/blob/9cd5ec4649db25ae675aec25f428f3ddf3d9a087/js-variables/eff.js#L153) function isValidIdentifier(value) { try { Function('"use strict"; let ' + value); return true; } catch (exception) { return false; } } function forceCamelCase(value) { return value .trim() .toLowerCase() .replace(/[-_\s]+(.)/g, (_, chr) => chr.toUpperCase()); } /** * Generates TS types for the given table, but with all slugs replaced with their labels for more readable code. * Code needed for mapping slugs to labels is also provided for use on objects returned by the API. * * IMPORTANT: If any labels are non-valid identifiers after custom options have been applied(in the current runtime), * string literals will be used in the final type. * * e.g: If a table has 3 records with labels "Record123", "Record with spaces", "test###", * the resulting mapping will map to: * * `{ * Record123: <type>, * "Record with spaces": <type>, * "test###": <type>, * }` * * For more reading on identifier names: https://mathiasbynens.be/notes/javascript-identifiers-es6 * @param metadata The metadata of the table to generate typings for * @param options specifies if spaces in label names should be replaced and/or names forced to camelCase * before the valid identifier check. * @see SmartSuiteAPI#getTableMetadata */ export async function generateTableTypings(metadata, options) { let resultTypeName = metadata.name.replace(/ /g, "") + "Record"; if (options?.recordTypeNameOverride) { resultTypeName = options.recordTypeNameOverride(metadata.name); } if (!isValidIdentifier(resultTypeName)) resultTypeName = "TableRecord" + metadata.slug; // Now, we iterate over the provided structure, which tells us which fields are present. // We do two things in parallel while iterating: // 1. We map out the shape with a set of pre-defined schemas based on the types of the fields. // 2. We map every slug that has a human-readable label counterpart to each-other, to be used when mapping // API responses when fetching actual data at a later point. (So we can index the response by label, and not slug) const slugToLabel = {}; const choiceValueToLabel = {}; let tableShape = {}; for (let structureElement of metadata.structure) { let fieldLabel = structureElement.label; const origLabel = fieldLabel; if (options?.forceCamelCase) { fieldLabel = forceCamelCase(fieldLabel); } if (options?.labelSpaceToken) { fieldLabel = fieldLabel.replace(/ /g, options.labelSpaceToken); } // If result of modifications is a non-valid identifier we revert to the old one... if (!isValidIdentifier(fieldLabel)) { console.warn("Modifications to field label resulted in a non-valid identifier: " + fieldLabel); console.warn("Reverting to original label: " + origLabel); fieldLabel = origLabel; } // All choices defined at field-settings level in SmartSuite are returned as slugs when fetching record data, // so we make sure to store a mapping to their labels for later mapping slugToLabel[structureElement.slug] = fieldLabel; switch (structureElement.field_type) { case "singleselectfield": case "multipleselectfield": case "statusfield": case "tagsfield": { const params = structureElement.params; const alternatives = params.choices.map(choice => choice.label); const choicesMapping = {}; params.choices.forEach(choice => choicesMapping[choice.value] = choice.label); choiceValueToLabel[structureElement.slug] = choicesMapping; tableShape[fieldLabel] = schemaBuilders[structureElement.field_type](alternatives); break; } // These fields have an option for either "multi" or "single" which limits how many entries the field can have. // We use this information to generate the typing as tightly as possible case "userfield": case "emailfield": case "phonefield": case "linkedrecordfield": case "socialnetworkfield": case "colorpickerfield": case "ipaddressfield": { const params = structureElement.params; const single = params.entries_allowed === "single"; tableShape[fieldLabel] = schemaBuilders[structureElement.field_type](single); break; } // The metadata tells us the max value of this field, but since TypeScript has no super elegant // way of specifying a range as a type we just manually type all possible numbers since the highest // possible max is just 10 case "ratingfield": { const params = structureElement.params; const scale = params.scale; const options = []; for (let i = 0; i < scale; i++) { options.push(i + 1); // Lowest value is always 1 } tableShape[fieldLabel] = schemaBuilders["ratingfield"](options); break; } default: { tableShape[fieldLabel] = schemaBuilders[structureElement.field_type](); } } } const tableSchema = z.object(tableShape); // Now, the table schema needs to be converted to TS. // Normally, the provided utils of zod-to-ts converts the schema to a type node. // (type TableRecord = {...}) // We need a class for proper implementation of the TableAPI interface, so after generating the type node // we convert the returned node to a class with some TS AST code... // (In practice the class works as a type, since we provide no constructor and give every // member the "!" token. Difference is the class is also an object, so it exists at runtime) const { node } = zodToTs(tableSchema, resultTypeName); // This should hopefully never error, used for typing if (!ts.isTypeLiteralNode(node)) { throw new Error("Expected a TypeLiteralNode from zod-to-ts"); } // Add "readonly" and "!" to all props const members = node.members.map(member => { if (!ts.isPropertySignature(member) || !member.type || !member.name) return undefined; return ts.factory.createPropertyDeclaration([ts.factory.createToken(ts.SyntaxKind.ReadonlyKeyword)], member.name, ts.factory.createToken(ts.SyntaxKind.ExclamationToken), member.type, undefined); }).filter(Boolean); // Create a class with new props as members const classNode = ts.factory.createClassDeclaration([ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], resultTypeName, undefined, undefined, members); const nodeString = printNode(classNode); const generatedTypesDir = path.join(import.meta.dirname, "generatedTyping"); // Wait, is there a folder for the generated types yet? if (!fs.lstatSync(generatedTypesDir)?.isDirectory()) throw new Error("Uh oh cant find the typing folder?"); // The schema has been converted to a class and printed, now the next step is building the rest of the generated code. // In this step we print the mapping records (slug -> label) and write the mapping function, // all wrapped in a class implementing TableAPI for ease of use later. const file = new Project({ tsConfigFilePath: path.join(generatedTypesDir, "tsconfig.json") }).createSourceFile(resultTypeName + ".ts", nodeString, { overwrite: true }); file.addImportDeclaration({ moduleSpecifier: "../typeGeneration.js", namedImports: ["TableRecordMapper"], isTypeOnly: true }); const mapperClassName = resultTypeName + "Mapper"; const clazz = file.addClass({ name: mapperClassName, implements: writer => { writer.write("TableRecordMapper<" + resultTypeName + ">"); }, isExported: true, }); clazz.addProperty({ isReadonly: true, name: "tableID", initializer: writer => writer.write("\"" + metadata.id + "\"") }); clazz.addProperty({ isReadonly: true, name: "recordModel", initializer: writer => writer.write(resultTypeName), }); clazz.addProperty({ isReadonly: true, name: "slugToLabel", initializer: writer => { writer.write(JSON.stringify(slugToLabel, null, 2)); writer.write(" as const"); }, type: "Record<string, string>", }); clazz.addProperty({ isReadonly: true, name: "choicesValueToLabel", initializer: writer => { writer.write(JSON.stringify(choiceValueToLabel, null, 2)); writer.write(" as const"); }, type: "Record<string, Record<string, string>>", }); clazz.addMethod({ name: "mapKeys", parameters: [{ name: "originalObject", type: "Record<string, any>", }], returnType: resultTypeName, statements: writer => { writer.write("return Object.fromEntries(\n" + " Object.entries(originalObject).map(([key, value]) => {\n" + " let finalValue = value\n" + " // We check if this key leads to either a choice id or an array of choice ids,\n" + " // then we use the map to switch out the id(s) with the label(s)\n" + " if(this.choicesValueToLabel[key]) {\n" + " if (Array.isArray(value) && value.length > 0) {\n" + " finalValue = value.map(v => {\n" + " if(typeof v !== \"string\") return v\n" + " // This kinda has to error out if the key is invalid, the map\n" + " // is expected to be complete as long as the metadata API is not bork\n" + " else return this.choicesValueToLabel[key][v]\n" + " })\n" + " } else {\n" + " if (typeof value === \"string\") finalValue = this.choicesValueToLabel[key][value]\n" + " }\n" + " }\n" + "\n" + " return [\n" + " this.slugToLabel[key] || key,\n" + " finalValue,\n" + " ]\n" + " })\n" + " ) as " + resultTypeName); } }); // Done(!!), now we save the files to the file system. file.emitSync(); addToTypeMap(metadata.id, metadata.name, mapperClassName, generatedTypesDir); } /** * We sacrifice TS compiler help here, I can't imagine a better typed alternative is more practical... */ export const schemaBuilders = { recordtitlefield: () => z.string(), richtextareafield: () => SmartDocSchema, firstcreatedfield: () => TimestampedUserSchema, lastupdatedfield: () => TimestampedUserSchema, userfield: (single) => buildMultiOrSingleFieldSchema(single, z.string()), commentscountfield: () => z.number(), autonumberfield: () => z.number(), textfield: () => z.string(), textareafield: () => z.string(), numberfield: () => z.string(), //TODO number as string numbersliderfield: () => z.number(), //TODO int type only? percentfield: () => z.string(), //TODO number as string currencyfield: () => z.string(), // TODO currency symbol will be missed in the response? yesnofield: () => z.boolean(), singleselectfield: (alternatives) => z.enum(alternatives), multipleselectfield: (alternatives) => z.array(z.enum(alternatives)), datefield: () => DateSchema, fullnamefield: () => FullNameSchema, emailfield: (single) => buildMultiOrSingleFieldSchema(single, z.string()), // TODO email validate phonefield: (single) => buildMultiOrSingleFieldSchema(single, PhoneSchema), addressfield: () => AddressSchema, linkfield: (single) => buildMultiOrSingleFieldSchema(single, z.string().url()), filefield: () => FileSchema, linkedrecordfield: (single) => buildMultiOrSingleFieldSchema(single, z.string()), timefield: () => z.string(), daterangefield: () => DateRangeSchema, percentcompletefield: () => z.string(), //TODO statusfield: (alternatives) => z.enum(alternatives), duedatefield: () => DateRangeSchema.extend({ is_overdue: z.boolean() }), dependencyfield: () => z.object({}), //TODO dependencyrelationfield: () => z.object({}), //TODO durationfield: () => z.object({ duration: z.number(), time_unit: z.string() //TODO type more strongly }), timetrackingfield: () => TimeTrackingSchema, checklistfield: () => ChecklistSchema, ratingfield: (options) => { const numbers = options.map((n) => z.literal(n)); return z.union(numbers); }, votefield: () => VoteSchema, socialnetworkfield: (single) => buildMultiOrSingleFieldSchema(single, SocialNetworkSchema), tagsfield: (alternatives) => { if (alternatives.length === 0) return z.tuple([]); return z.enum(alternatives); //TODO this seems to always have 2 default options? (in addition to user provided ones) }, recordidfield: () => z.string(), signaturefield: () => SignatureSchema, countfield: () => z.string(), //TODO number as string, infer subitemsfield: () => SubItemsSchema, buttonfield: () => z.union([z.string(), z.null()]), colorpickerfield: (single) => buildMultiOrSingleFieldSchema(single, ColorPickerSchema), ipaddressfield: (single) => buildMultiOrSingleFieldSchema(single, IpAddressSchema), //TODO type strongly for min/max? rollupfield: () => z.string(), //TODO number as text, infer formulafield: () => z.any(), //TODO this is the formula result, Number|string|datetime based on structure lookupfield: () => z.any(), //TODO this is an array of the target field type }; /** * Based on if the field accepts a single or multiple entries we can enforce more specific typings. * @param single true if field only accepts a single entry, false if multiple * @param entrySchema the schema of the entry this field accepts */ function buildMultiOrSingleFieldSchema(single, entrySchema) { if (single) return z.union([z.tuple([]), z.tuple([entrySchema])]); return z.array(entrySchema); } const SmartDocSchema = z.object({ // This is either {} or a SmartDoc content object data: z.union([z.object({ type: z.literal("doc"), content: z.array(z.object({ type: z.string(), attrs: z.object({}), //TODO content: z.object({ type: z.string(), text: z.string(), }) })) }), z.object({})]), html: z.string(), preview: z.string(), yjsData: z.optional(z.string()) }); const TimestampedUserSchema = z.object({ on: z.string(), by: z.string() }); const DateSchema = z.object({ date: z.union([z.string().transform(s => new Date(s)), z.null()]), include_time: z.boolean() }); const FullNameSchema = z.object({ first_name: z.optional(z.string()), middle_name: z.optional(z.string()), last_name: z.optional(z.string()), sys_root: z.optional(z.string()), }); const PhoneSchema = z.object({ phone_country: z.string(), phone_number: z.string(), phone_extension: z.string(), phone_type: z.number() //TODO?? }); const AddressSchema = z.object({ location_address: z.string(), location_address2: z.string(), location_city: z.string(), location_state: z.string(), location_zip: z.string(), location_country: z.string(), location_longitude: z.string(), location_latitude: z.string(), sys_root: z.string() }); const FileSchema = z.object({ handle: z.string(), metadata: z.object({ container: z.string(), // TODO probably literals filename: z.string(), key: z.string(), mimetype: z.string(), size: z.number() }), transform_options: z.object({}), //TODO security: z.object({ policy: z.string(), signature: z.string() }), file_type: z.string(), created_on: z.string().transform(s => new Date(s)), updated_on: z.string().transform(s => new Date(s)), description: z.union([z.null(), SmartDocSchema]), //TODO record: z.object({ application_id: z.string(), record_id: z.string() }), icon: z.string() }); const DateRangeSchema = z.object({ from_date: DateSchema, to_date: DateSchema }); const TimeTrackingSchema = z.object({ time_tracking_logs: z.array(z.object({ user_id: z.string(), date_time: z.string().transform(s => new Date(s)), duration: z.number(), time_range: z.union([DateRangeSchema.extend({ _cls: z.string() /*TODO*/ }), z.null()]), note: z.string(), })), total_duration: z.number() }); const ChecklistSchema = z.object({ items: z.array(z.object({ id: z.string(), content: SmartDocSchema, completed: z.boolean(), assignee: z.union([z.string(), z.null()]), due_date: z.union([z.string().transform(s => new Date(s)), z.null()]), //TODO, docs says this is only YYYY-MM-DD completed_at: z.union([z.string().transform(s => new Date(s)), z.null()]), })), total_items: z.number(), completed_items: z.number() }); const VoteSchema = z.object({ total_votes: z.number(), votes: z.array(z.object({ user_id: z.string(), date: z.string() // TODO returned as YYYY-MM-DD??? })) }); const SocialNetworkSchema = z.object({ facebook_username: z.string(), instagram_username: z.string(), linkedin_username: z.string(), twitter_username: z.string(), sys_root: z.string() }); const SignatureSchema = z.object({ text: z.union([z.string(), z.null()]), // If this is nonnull, drawing is empty and image_base64 is null drawing: z.array(FileSchema), //TODO discriminated union?? image_base64: z.union([z.string(), z.null()]), //Image encoded in basee64 }); const SubItemsSchema = z.object({ count: z.number(), items: z.array(z.object({ id: z.string(), first_created: TimestampedUserSchema, last_updated: TimestampedUserSchema, // TODO this object can include more data, its like a record structure, but with a little less possibilities // Metadata defines structure here as well, typing should be possible to do quite strict })) }); const ColorPickerSchema = z.object({ value: z.string(), name: z.any(), }); const IpAddressSchema = z.object({ address: z.string(), //TODO ip validate country_code: z.string(), });