|
| 1 | +import type { RequestRoute, Server } from "@hapi/hapi"; |
| 2 | +import Joi from "joi"; |
| 3 | +import type ts from "typescript"; |
| 4 | +import { addSyntheticLeadingComment, factory as f } from "typescript"; |
| 5 | + |
| 6 | +import type { ExtendedObjectSchema, ExtendedSchema } from "./joi.ts"; |
| 7 | +import { |
| 8 | + typeAliasDeclaration, |
| 9 | + typeLiteralNode, |
| 10 | +} from "./nodes.ts"; |
| 11 | +import { |
| 12 | + generateType, |
| 13 | +} from "./types.ts"; |
| 14 | +import { createPrinter, EmitHint, factory, NodeFlags, SyntaxKind } from "typescript"; |
| 15 | + |
| 16 | +/** Generates a friendly name from a route |
| 17 | + * This function uses the manually specified `id` if one is provided |
| 18 | + * If not, a name is generated automatically using the route's method |
| 19 | + * and path. |
| 20 | + * |
| 21 | + * GET /foo/bar -> getFooBar |
| 22 | + * POST /users/@me/tax-status -> postUsersSelfTaxStatus |
| 23 | + */ |
| 24 | +export function getRouteName(route: RequestRoute): string { |
| 25 | + if (route.settings.id) { |
| 26 | + return route.settings.id; |
| 27 | + } |
| 28 | + |
| 29 | + const segments = route.path.split("/") |
| 30 | + .filter((segment) => segment) // && !segment.startsWith("{")) |
| 31 | + .map((segment) => { |
| 32 | + if (segment === "@me") { |
| 33 | + segment = "self"; |
| 34 | + } |
| 35 | + |
| 36 | + if (segment.startsWith("{")) { |
| 37 | + segment = camelCase(segment.slice(1, -1)); |
| 38 | + } else { |
| 39 | + segment = segment.replace(/-([^-])/g, (_, p: string) => p.toUpperCase()); |
| 40 | + } |
| 41 | + |
| 42 | + return [segment[0].toUpperCase(), ...segment.slice(1)].join(""); |
| 43 | + }); |
| 44 | + |
| 45 | + const name = `${route.method.toLowerCase()}${segments.join("")}`; |
| 46 | + return name; |
| 47 | +} |
| 48 | + |
| 49 | +export function getTypeName(routeName: string, suffix: string): string { |
| 50 | + return `${routeName[0].toUpperCase()}${routeName.slice(1)}${suffix[0].toUpperCase()}${suffix.slice(1)}`; |
| 51 | +} |
| 52 | + |
| 53 | +export function generateClientType(server: Server) { |
| 54 | + const routeMap: Record<string, Record<string, Record<string, { name: string; required: boolean }>>> = {}; |
| 55 | + const statements: ts.Statement[] = []; |
| 56 | + |
| 57 | + for (const route of server.table()) { |
| 58 | + const routeName = getRouteName(route); |
| 59 | + const routeOptions: Record<string, { name: string; required: boolean }> = {}; |
| 60 | + |
| 61 | + const paramNames: string[] = (route.path.match(/{[^}]+}/g) ?? []).map((name: string) => name.slice(1, -1)); |
| 62 | + if (paramNames.length) { |
| 63 | + // map parameter names to validators, use strings by default |
| 64 | + const paramValidators: Record<string, ExtendedSchema> = paramNames.reduce((result, param) => { |
| 65 | + return { ...result, [param]: Joi.string() }; |
| 66 | + }, {}); |
| 67 | + |
| 68 | + // if more specific param validators are specified, use those to override |
| 69 | + if (route.settings.validate?.params) { |
| 70 | + const params = route.settings.validate.params as ExtendedObjectSchema; |
| 71 | + for (const term of params.$_terms.keys ?? []) { |
| 72 | + paramValidators[term.key] = term.schema.required() as ExtendedSchema; |
| 73 | + } |
| 74 | + } |
| 75 | + |
| 76 | + const paramsTypeName = getTypeName(routeName, "params"); |
| 77 | + routeOptions.params = { |
| 78 | + name: paramsTypeName, |
| 79 | + required: true, |
| 80 | + }; |
| 81 | + statements.push(typeAliasDeclaration(paramsTypeName, generateType(Joi.object(paramValidators) as ExtendedObjectSchema), true)); |
| 82 | + } |
| 83 | + |
| 84 | + if (route.settings.validate?.headers) { |
| 85 | + const headersTypeName = getTypeName(routeName, "headers"); |
| 86 | + routeOptions.headers = { |
| 87 | + name: headersTypeName, |
| 88 | + required: isRequired(route.settings.validate.headers as ExtendedSchema), |
| 89 | + }; |
| 90 | + statements.push(typeAliasDeclaration(headersTypeName, generateType(route.settings.validate.headers as ExtendedObjectSchema), true)); |
| 91 | + } |
| 92 | + |
| 93 | + if (route.settings.validate?.query) { |
| 94 | + const queryTypeName = getTypeName(routeName, "query"); |
| 95 | + routeOptions.query = { |
| 96 | + name: queryTypeName, |
| 97 | + required: isRequired(route.settings.validate.query as ExtendedSchema), |
| 98 | + }; |
| 99 | + statements.push(typeAliasDeclaration(queryTypeName, generateType(route.settings.validate.query as ExtendedObjectSchema), true)); |
| 100 | + } |
| 101 | + |
| 102 | + if (route.settings.validate?.payload) { |
| 103 | + const payloadTypeName = getTypeName(routeName, "payload"); |
| 104 | + routeOptions.payload = { |
| 105 | + name: payloadTypeName, |
| 106 | + required: isRequired(route.settings.validate.payload as ExtendedSchema), |
| 107 | + }; |
| 108 | + statements.push(typeAliasDeclaration(payloadTypeName, generateType(route.settings.validate.payload as ExtendedObjectSchema), true)); |
| 109 | + } |
| 110 | + |
| 111 | + const responseTypeName = getTypeName(routeName, "response"); |
| 112 | + const responseValidator = Object.entries(route.settings.response?.status ?? {}) |
| 113 | + .filter(([code]) => +code >= 200 && +code < 300) |
| 114 | + .map(([_, schema]) => schema)[0] ?? Joi.any(); |
| 115 | + routeOptions.response = { |
| 116 | + name: responseTypeName, |
| 117 | + required: true, |
| 118 | + }; |
| 119 | + statements.push(typeAliasDeclaration(responseTypeName, generateType(responseValidator as ExtendedObjectSchema), true)); |
| 120 | + |
| 121 | + routeMap[route.method.toUpperCase()] ??= {}; |
| 122 | + routeMap[route.method.toUpperCase()][route.path] = routeOptions; |
| 123 | + } |
| 124 | + |
| 125 | + const clientTypeNode = f.createTypeLiteralNode(Object.entries(routeMap).map(([method, route]) => { |
| 126 | + return f.createPropertySignature( |
| 127 | + [], |
| 128 | + f.createStringLiteral(method), |
| 129 | + undefined, |
| 130 | + f.createTypeLiteralNode(Object.entries(route).map(([path, options]) => { |
| 131 | + if (path === "/audits/{audit_uid}/users") { |
| 132 | + console.log(options); |
| 133 | + } |
| 134 | + return f.createPropertySignature( |
| 135 | + [], |
| 136 | + f.createStringLiteral(path), |
| 137 | + undefined, |
| 138 | + typeLiteralNode(options), |
| 139 | + ); |
| 140 | + }))); |
| 141 | + })); |
| 142 | + |
| 143 | + statements.push(typeAliasDeclaration("RouteMap", clientTypeNode, true)); |
| 144 | + |
| 145 | + addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/indent ", true); |
| 146 | + addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/quote-props ", true); |
| 147 | + addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/comma-dangle ", true); |
| 148 | + |
| 149 | + const sourceFile = factory.createSourceFile(statements, factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None); |
| 150 | + const printer = createPrinter(); |
| 151 | + const clientType = printer.printNode(EmitHint.SourceFile, sourceFile, sourceFile); |
| 152 | + console.error(clientType); |
| 153 | + return clientType; |
| 154 | +} |
| 155 | + |
| 156 | +function isRequired(schema: ExtendedSchema) { |
| 157 | + return schema._flags.presence === "required"; |
| 158 | +} |
| 159 | + |
| 160 | +function camelCase(value: string) { |
| 161 | + return value.replace(/_(.)/g, (_, p: string) => p.toUpperCase()); |
| 162 | +} |
0 commit comments