UNPKG

@compas/code-gen

Version:

Generate various boring parts of your server

461 lines (403 loc) 12.8 kB
import { existsSync } from "fs"; import { AppError, isNil, merge, newLogger, pathJoin, printProcessMemoryUsage, } from "@compas/stdlib"; import { buildOrInfer } from "./builders/utils.js"; import { generateTypes } from "./generate-types.js"; import { addGroupsToGeneratorInput } from "./generate.js"; import { validateCodeGenNamePart, validateCodeGenStructure, validateCodeGenType, } from "./generated/codeGen/validators.js"; import { generate } from "./generator/index.js"; import { generateOpenApi } from "./generator/openAPI/index.js"; import { getInternalRoutes } from "./generator/router/index.js"; import { loadFromOpenAPISpec } from "./loaders.js"; import { structureAddType } from "./structure/structureAddType.js"; import { structureHoistNamedItems } from "./structure/structureHoistNamedItems.js"; import { structureIteratorNamedTypes } from "./structure/structureIterators.js"; import { structureRemoveInternalFields } from "./structure/structureRemoveInternalFields.js"; import { lowerCaseFirst } from "./utils.js"; /** * @typedef {object} GenerateOpts * @property {string[]|undefined} [enabledGroups] Enabling specific groups so different * generator combinations can be used. The machinery will automatically find * referenced types and include those If this is undefined, all groups will be * enabled. * @property {boolean|undefined} [isBrowser] * @property {boolean|undefined} [isNode] * @property {boolean|undefined} [isNodeServer] * @property {object} [environment] Options for conforming the output based on the * specified environment and runtime. * @property {"browser"|"react-native"} [environment.clientRuntime] Switch api client * generation and corresponding types based on the available globals. * @property {( * "type"| * "validator"| * "router"| * "sql"| * "apiClient"| * "reactQuery" * )[]|undefined} [enabledGenerators] Enabling specific generators. * @property {boolean|undefined} [useTypescript] Enable Typescript for the generators * that support it * @property {boolean|undefined} [dumpStructure] Dump a structure.js file with the used * structure in it. * @property {boolean|undefined} [dumpApiStructure] An api only variant of * 'dumpStructure'. Includes all referenced types by defined 'route' types. * @property {boolean|undefined} [dumpPostgres] Dump a structure.sql based on all * 'enableQueries' object types. * @property {string|undefined} [fileHeader] Custom file header. * @property {string} outputDirectory Directory to write files to. Note that this is * recursively cleaned before writing the new files. * @property {false|undefined} [declareGlobalTypes] */ /** * @type {Partial<GenerateOpts>} */ const defaultGenerateOptionsBrowser = { isBrowser: true, isNodeServer: false, isNode: false, enabledGenerators: ["type", "apiClient", "reactQuery"], environment: { clientRuntime: "browser", }, useTypescript: true, dumpStructure: false, dumpApiStructure: false, dumpPostgres: false, }; /** * @type {Partial<GenerateOpts>} */ const defaultGenerateOptionsNodeServer = { isBrowser: false, isNodeServer: true, isNode: true, enabledGenerators: ["type", "validator", "sql", "router", "apiClient"], environment: {}, useTypescript: false, dumpStructure: false, dumpApiStructure: true, dumpPostgres: true, }; /** * @type {Partial<GenerateOpts>} */ const defaultGenerateOptionsNode = { isBrowser: false, isNodeServer: false, isNode: true, enabledGenerators: ["type", "validator"], environment: {}, useTypescript: false, dumpStructure: false, dumpApiStructure: false, dumpPostgres: false, }; /** * @class */ export class App { /** * @type {string[]} */ static defaultEslintIgnore = ["no-unused-vars"]; /** * Create a new App. * * @param {{ verbose?: boolean }} [options={}] */ constructor(options = {}) { /** * @private * @type {string} */ this.fileHeader = `// Generated by @compas/code-gen\n`; /** * @type {boolean} */ this.verbose = options?.verbose ?? false; /** * @type {Logger} */ this.logger = newLogger({ ctx: { type: "code_gen", }, }); /** @type {Set<TypeBuilderLike>} */ this.unprocessedData = new Set(); /** @type {import("./generated/common/types").CodeGenStructure} */ this.data = {}; } /** * @param {...TypeBuilderLike} builders * @returns {this} */ add(...builders) { for (const builder of builders) { this.unprocessedData.add(builder); } return this; } /** * @param {Record<string, any>} obj * @returns {this} */ addRaw(obj) { if (!isNil(validateCodeGenType)) { // Validators present, use the result of them. const { value, error } = validateCodeGenType(obj); if (error) { this.logger.error(error); process.exit(1); } // Make a deep copy without null prototypes obj = {}; merge(obj, value); } this.addToData(obj); return this; } /** * @param data * @returns {this} */ extend(data) { return this.extendInternal(data, false); } /** * Extend from the OpenAPI spec * * @param {string} defaultGroup * @param {Record<string, any>} data * @returns {this} */ extendWithOpenApi(defaultGroup, data) { const { error } = validateCodeGenNamePart(defaultGroup); if (error) { throw AppError.serverError({ message: `Specified default group name '${defaultGroup}' is not valid. Expects only lowercase and uppercase characters.`, }); } return this.extendInternal(loadFromOpenAPISpec(defaultGroup, data), true); } /** * @param {import("./generator/openAPI").GenerateOpenApiOpts} options * @returns {Promise<void>} */ async generateOpenApi(options) { options.verbose = options.verbose ?? this.verbose; if (isNil(options?.outputFile)) { throw new Error("Need options.outputFile to write file to."); } if (isNil(options?.inputPath)) { throw new Error("Need options.inputPath for compas structure"); } const inputStructure = pathJoin(options.inputPath, "common/structure.js"); if (!existsSync(inputStructure)) { throw new Error( `Invalid inputPath '${options.inputPath}'. '${inputStructure}' does not exists. Is it correctly generated?`, ); } options.inputPath = inputStructure; await generateOpenApi(this.logger, options); } /** * @param {import("./generate-types").GenerateTypeOpts} options * @returns {Promise<void>} */ async generateTypes(options) { if (isNil(options?.outputDirectory)) { throw new Error("Need options.outputDirectory to write files to."); } for (const path of options.inputPaths) { const inputStructure = pathJoin(path, "common/structure.js"); if (!existsSync(inputStructure)) { throw new Error( `Invalid inputPath '${path}'. '${inputStructure}' does not exists. Is it correctly generated?`, ); } } options.verbose = options.verbose ?? this.verbose; options.fileHeader = this.fileHeader + formatEslint() + (options.fileHeader ?? ""); await generateTypes(this.logger, options); } /** * @param {GenerateOpts} options * @returns {Promise<void>} */ async generate(options) { if (isNil(options?.outputDirectory)) { throw new Error("Need options.outputDirectory to write files to."); } if ( isNil(options.isBrowser) && isNil(options.isNodeServer) && isNil(options.isNode) && isNil(options.enabledGenerators) ) { throw new Error( `Either options.isBrowser, options.isNodeServer, options.isNode or options.enabledGenerators must be set.`, ); } options.enabledGenerators = options.enabledGenerators || []; const opts = { outputDirectory: options.outputDirectory, fileHeader: this.fileHeader + formatEslint() + (options.fileHeader ?? ""), }; if ( options.isBrowser || options.enabledGenerators.indexOf("reactQuery") !== -1 ) { Object.assign(opts, defaultGenerateOptionsBrowser); } else if ( options.isNodeServer || options.enabledGenerators.indexOf("router") !== -1 ) { Object.assign(opts, defaultGenerateOptionsNodeServer); } else if (options.isNode) { Object.assign(opts, defaultGenerateOptionsNode); } opts.useTypescript = options.useTypescript ?? !!opts.useTypescript; opts.declareGlobalTypes = options.declareGlobalTypes; opts.dumpStructure = options.dumpStructure ?? !!opts.dumpStructure; opts.dumpApiStructure = options.dumpApiStructure ?? !!opts.dumpApiStructure; opts.dumpPostgres = options.dumpPostgres ?? !!opts.dumpPostgres; opts.enabledGenerators = options.enabledGenerators.length > 0 ? options.enabledGenerators : opts.enabledGenerators ?? []; if (options.environment) { opts.environment = { ...(opts.environment ?? {}), ...options.environment, }; } // Quick hack so we can test if we have generated // before running the tests. // @ts-ignore if (options.returnFiles) { opts.returnFiles = true; } // Add internal routes for (const r of getInternalRoutes(opts)) { this.unprocessedData.add(r); } this.processData(); structureHoistNamedItems(this.data); // Make sure to do the same case conversion here as well as to not confuse the user. // Other than that we don't mutate this array. opts.enabledGroups = options.enabledGroups?.map((it) => lowerCaseFirst(it)); if ( (opts.enabledGroups?.length ?? 0) === 0 && Object.keys(this.data).length === 0 ) { throw new Error( "Need at least a single group in enabledGroups, or no `enabledGroups` provided which defaults to all groups.", ); } const groupsToInclude = opts.enabledGroups ? [...opts.enabledGroups] : Object.keys(this.data); // Make sure _compas/structure.json is enabled. // This is only needed when we have a router and dumpApiStructure is true if ( opts.enabledGenerators.indexOf("router") !== -1 && opts.dumpApiStructure ) { groupsToInclude.push("compas"); } if ( opts.enabledGenerators.includes("validator") && (opts.enabledGenerators.includes("reactQuery") || opts.useTypescript) ) { throw new Error( "The 'validator' generator can't be used in combination with the 'reactQuery' generator or with the 'useTypescript' option.", ); } // Ensure that we don't mutate the current working data of the user const dataCopy = JSON.parse(JSON.stringify(this.data)); /** @type {import("./generated/common/types").CodeGenStructure} */ const generatorInput = {}; addGroupsToGeneratorInput(generatorInput, dataCopy, groupsToInclude); // validators may not be present, fallback to just stringify if (!isNil(validateCodeGenStructure)) { const { error } = validateCodeGenStructure(generatorInput); if (error) { this.logger.error(error); process.exit(1); } } const result = await generate(this.logger, opts, generatorInput); if (this.verbose) { printProcessMemoryUsage(this.logger); } return result; } /** * Internally used extend * * @private * * @param {Record<string, any>} rawStructure * @param {boolean} allowInternalProperties * @returns {this} */ extendInternal(rawStructure, allowInternalProperties) { if (!isNil(validateCodeGenType)) { // Validators present, use the result of them. const { value, error } = validateCodeGenStructure(rawStructure); if (error) { this.logger.error(error); process.exit(1); } // Make a deep copy without null prototypes rawStructure = {}; merge(rawStructure, value); } if (!allowInternalProperties) { structureRemoveInternalFields(rawStructure); } for (const type of structureIteratorNamedTypes(rawStructure)) { this.addToData(type); } return this; } /** * Process unprocessed list, normalize references * Depends on referentType being available * * @private */ processData() { for (const item of this.unprocessedData) { this.addToData(buildOrInfer(item)); } this.unprocessedData.clear(); } /** * @private * @param item */ addToData(item) { structureAddType(this.data, item); } } /** * Format eslint-disable comment * * @returns {string} */ function formatEslint() { return `/* eslint-disable ${App.defaultEslintIgnore.join(", ")} */\n`; }