UNPKG

@compas/code-gen

Version:

Generate various boring parts of your server

475 lines (396 loc) 12.9 kB
import { AnyType } from "../../builders/index.js"; import { upperCaseFirst } from "../../utils.js"; import { fileBlockEnd, fileBlockStart } from "../file/block.js"; import { fileContextAddLinePrefix, fileContextCreateGeneric, fileContextGetOptional, fileContextRemoveLinePrefix, fileContextSetIndent, } from "../file/context.js"; import { fileWrite } from "../file/write.js"; import { routeStructureGet } from "../processors/route-structure.js"; import { JavascriptImportCollector } from "../target/javascript.js"; import { typesCacheGet } from "../types/cache.js"; import { typesGeneratorGenerateNamedType, typesGeneratorUseTypeName, } from "../types/generator.js"; /** * Get the router file for the provided group * * @param {import("../generate").GenerateContext} generateContext */ export function jsKoaGetRouterFile(generateContext) { let file = fileContextGetOptional(generateContext, `common/router.js`); if (file) { return file; } file = fileContextCreateGeneric(generateContext, `common/router.js`, { importCollector: new JavascriptImportCollector(), }); const importCollector = JavascriptImportCollector.getImportCollector(file); importCollector.destructure("@compas/stdlib", "AppError"); return file; } /** * Get the controller file for the provided group * * @param {import("../generate").GenerateContext} generateContext * @param {string} group */ export function jsKoaGetControllerFile(generateContext, group) { let file = fileContextGetOptional(generateContext, `${group}/controller.js`); if (file) { return file; } file = fileContextCreateGeneric(generateContext, `${group}/controller.js`, { importCollector: new JavascriptImportCollector(), }); const importCollector = JavascriptImportCollector.getImportCollector(file); importCollector.destructure("@compas/stdlib", "AppError"); return file; } /** * Create Ctx & Fn types * * @param {import("../generate").GenerateContext} generateContext * @param {import("../file/context").GenerateFile} file * @param {import("../generated/common/types").ExperimentalRouteDefinition} route * @param {Record<string, string>} contextNames */ export function jsKoaPrepareContext( generateContext, file, route, contextNames, ) { let partial = ``; partial += contextNames.paramsType ? ` validatedParams: ${contextNames.paramsTypeName},\n` : ""; partial += contextNames.queryType ? ` validatedQuery: ${contextNames.queryTypeName},\n` : ""; partial += contextNames.filesType ? ` validatedFiles: ${contextNames.filesTypeName},\n` : ""; partial += contextNames.bodyType ? ` validatedBody: ${contextNames.bodyTypeName},\n` : ""; const ctxType = new AnyType(route.group, `${route.name}Ctx`) .implementations({ js: { validatorOutputType: `import("koa").ExtendableContext & { event: import("@compas/stdlib").InsightEvent, log: import("@compas/stdlib").Logger,${ partial.length > 0 ? `\n ${partial.trim()}` : "" } } & { body: ${contextNames.responseTypeName ?? "any"} }`, validatorInputType: "any", }, }) .build(); typesGeneratorGenerateNamedType(generateContext, ctxType, { validatorState: "output", nameSuffixes: { input: "Input", output: "Validated", }, targets: ["js"], }); // @ts-expect-error contextNames[`ctxTypeName`] = typesCacheGet(generateContext, ctxType, { validatorState: "output", nameSuffixes: { input: "Input", output: "Validated", }, targets: ["js"], }); contextNames[`ctxType`] = typesGeneratorUseTypeName( generateContext, file, contextNames[`ctxTypeName`], ); const fnType = new AnyType(route.group, `${route.name}Fn`) .implementations({ js: { validatorOutputType: `( ctx: ${upperCaseFirst(route.group ?? "")}${upperCaseFirst( route.name ?? "", )}Ctx ) => void | Promise<void>`, validatorInputType: "any", }, }) .build(); typesGeneratorGenerateNamedType(generateContext, fnType, { validatorState: "output", nameSuffixes: { input: "Input", output: "Validated", }, targets: ["js"], }); // @ts-expect-error contextNames[`fnTypeName`] = typesCacheGet(generateContext, fnType, { validatorState: "output", nameSuffixes: { input: "Input", output: "Validated", }, targets: ["js"], }); contextNames[`fnType`] = typesGeneratorUseTypeName( generateContext, file, contextNames[`fnTypeName`], ); } /** * @param {import("../file/context").GenerateFile} file * @param {string} group * @param {import("../generated/common/types").ExperimentalRouteDefinition[]} routes * @param {Map<any, Record<string, string>>} contextNamesMap */ export function jsKoaWriteHandlers(file, group, routes, contextNamesMap) { fileWrite(file, `/**`); fileContextAddLinePrefix(file, ` * `); fileWrite(file, `${group} route handlers\n`); fileWrite(file, `@type {{`); fileContextSetIndent(file, 1); for (const route of routes) { const contextNames = contextNamesMap.get(route) ?? {}; fileWrite(file, `${route.name}: ${contextNames.fnType},`); } fileContextSetIndent(file, -1); fileWrite(file, `}}`); fileContextRemoveLinePrefix(file, 1); fileWrite(file, "/"); fileContextRemoveLinePrefix(file, 2); fileWrite(file, `export const ${group}Handlers = {`); fileContextSetIndent(file, 1); for (const route of routes) { fileWrite( file, `${route.name}: (ctx) => { throw AppError.notImplemented({ message: "You probably forgot to override the generated handlers or import your own implementation." }) },`, ); } fileContextSetIndent(file, -1); fileWrite(file, `};\n`); } /** * @param {import("../file/context").GenerateFile} file * @param {string} group * @param {import("../generated/common/types").ExperimentalRouteDefinition[]} routes */ export function jsKoaWriteTags(file, group, routes) { fileWrite(file, `export const ${group}Tags = {`); fileContextSetIndent(file, 1); for (const route of routes) { fileWrite(file, `${route.name}: ${JSON.stringify(route.tags)},`); } fileContextSetIndent(file, -1); fileWrite(file, `};\n`); } /** * @param {import("../file/context").GenerateFile} file * @param {Record<string, import("../generated/common/types").ExperimentalRouteDefinition[]>} routesPerGroup * @param {Map<any, Record<string, string>>} contextNamesMap */ export function jsKoaBuildRouterFile(file, routesPerGroup, contextNamesMap) { const importCollector = JavascriptImportCollector.getImportCollector(file); importCollector.destructure("./route-matcher.js", "routeMatcher"); importCollector.destructure("@compas/stdlib", "eventRename"); for (const group of Object.keys(routesPerGroup)) { importCollector.destructure( `../${group}/controller.js`, `${group}Handlers`, ); } fileWrite(file, `/**`); fileContextAddLinePrefix(file, ` * `); fileWrite(file, `The full router and dispatching\n`); fileWrite(file, `@param {import("@compas/server").BodyParserPair} parsers`); fileWrite(file, `@returns {import("@compas/server").Middleware}`); fileContextRemoveLinePrefix(file, 1); fileWrite(file, `/`); fileContextRemoveLinePrefix(file, 2); fileBlockStart(file, `export function router(parsers)`); fileWrite(file, `const routes = {`); fileContextSetIndent(file, 1); for (const [group, routes] of Object.entries(routesPerGroup)) { fileWrite(file, `${group}: {`); fileContextSetIndent(file, 1); for (const route of routes) { const contextNames = contextNamesMap.get(route) ?? {}; fileWrite(file, `${route.name}: async (params, ctx, next) => {`); fileContextSetIndent(file, 1); fileBlockStart(file, `if (ctx.event)`); fileWrite( file, `eventRename(ctx.event, "router.${route.group}.${route.name}");`, ); fileBlockEnd(file); fileWrite(file, `ctx.request.params = params;`); if (route.files) { fileWrite(file, `await parsers.multipartBodyParser(ctx);`); } else if (route.body || route.query) { fileWrite(file, `await parsers.bodyParser(ctx);`); } if (route.params) { fileWrite( file, `const validatedParams = ${ contextNames[`paramsValidator`] }(ctx.request.params);`, ); fileBlockStart(file, `if (validatedParams.error)`); fileWrite( file, `throw AppError.validationError("validator.error", validatedParams.error);`, ); fileBlockEnd(file); fileBlockStart(file, `else`); fileWrite(file, `ctx.validatedParams = validatedParams.value;`); fileBlockEnd(file); } if (route.query) { fileWrite( file, `const validatedQuery = ${ contextNames[`queryValidator`] }(ctx.request.query);`, ); fileBlockStart(file, `if (validatedQuery.error)`); fileWrite( file, `throw AppError.validationError("validator.error", validatedQuery.error);`, ); fileBlockEnd(file); fileBlockStart(file, `else`); fileWrite(file, `ctx.validatedQuery = validatedQuery.value;`); fileBlockEnd(file); } if (route.files) { fileWrite( file, `const validatedFiles = ${ contextNames[`filesValidator`] }(ctx.request.files);`, ); fileBlockStart(file, `if (validatedFiles.error)`); fileWrite( file, `throw AppError.validationError("validator.error", validatedFiles.error);`, ); fileBlockEnd(file); fileBlockStart(file, `else`); fileWrite(file, `ctx.validatedFiles = validatedFiles.value;`); fileBlockEnd(file); } if (route.body) { fileWrite( file, `const validatedBody = ${ contextNames[`bodyValidator`] }(ctx.request.body);`, ); fileBlockStart(file, `if (validatedBody.error)`); fileWrite( file, `throw AppError.validationError("validator.error", validatedBody.error);`, ); fileBlockEnd(file); fileBlockStart(file, `else`); fileWrite(file, `ctx.validatedBody = validatedBody.value;`); fileBlockEnd(file); } fileWrite(file, `await ${group}Handlers.${route.name}(ctx);`); if (route.response) { fileWrite( file, `const validatedResponse = ${ contextNames[`responseValidator`] }(ctx.body);`, ); fileBlockStart(file, `if (validatedResponse.error)`); fileWrite( file, `throw AppError.serverError({ message: "Response did not satisfy the response type.", route: { group: "${route.group}", name: "${route.name}", }, error: validatedResponse.error });`, ); fileBlockEnd(file); fileBlockStart(file, `else`); fileWrite(file, `ctx.body = validatedResponse.value;`); fileBlockEnd(file); } fileWrite(file, `return next();`); fileContextSetIndent(file, -1); fileWrite(file, `},`); } fileContextSetIndent(file, -1); fileWrite(file, `},`); } fileContextSetIndent(file, -1); fileWrite(file, `};\n`); fileBlockStart(file, `return function (ctx, next)`); fileWrite(file, `const match = routeMatcher(ctx.method, ctx.path);\n`); fileWrite(file, `if (!match) { throw AppError.notFound(); }\n`); // const decodePathParam = (arg) => { // try { // return decodeURIComponent(arg); // } catch (e) { // throw AppError.validationError("router.param.invalidEncoding", { // param: arg, // }); // } // }; fileBlockStart(file, `if (match.params)`); fileBlockStart( file, `for (const [key, value] of Object.entries(match.params))`, ); fileWrite( file, `try { match.params[key] = decodeURIComponent(value); } catch (e) { throw AppError.validationError("router.param.invalidEncoding", { key, value }); } `, ); fileBlockEnd(file); fileBlockEnd(file); fileWrite( file, `return routes[match.route.group][match.route.name](match.params, ctx, next);`, ); fileBlockEnd(file); fileBlockEnd(file); } /** * * @param {import("../generate").GenerateContext} generateContext * @param {import("../file/context").GenerateFile} file */ export function jsKoaRegisterCompasStructureRoute(generateContext, file) { if (!generateContext.options.generators.router?.exposeApiStructure) { return; } fileWrite( file, `compasHandlers.structure = (ctx) => { ctx.set("Content-Type", "application/json"); ctx.body = \`${routeStructureGet(generateContext)}\`; }; `, ); }