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
JavaScript
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(),
});