|
1 | 1 | import type { RequestRoute, Server } from "@hapi/hapi";
|
2 | 2 | import Joi from "joi";
|
3 | 3 | import type ts from "typescript";
|
4 |
| -import { addSyntheticLeadingComment, factory as f } from "typescript"; |
| 4 | +import { addSyntheticLeadingComment } from "typescript"; |
5 | 5 |
|
6 | 6 | import type { ExtendedObjectSchema, ExtendedSchema } from "./joi.ts";
|
7 | 7 | import {
|
| 8 | + importDeclaration, |
8 | 9 | typeAliasDeclaration,
|
9 | 10 | typeLiteralNode,
|
| 11 | + typeReferenceNode, |
| 12 | + unionTypeNode, |
10 | 13 | } from "./nodes.ts";
|
11 | 14 | import {
|
12 | 15 | generateType,
|
@@ -51,9 +54,11 @@ export function getTypeName(routeName: string, suffix: string): string {
|
51 | 54 | }
|
52 | 55 |
|
53 | 56 | export function generateClientType(server: Server) {
|
54 |
| - const routeMap: Record<string, Record<string, Record<string, { name: string; required: boolean }>>> = {}; |
| 57 | + const routeList: ts.TypeNode[] = []; |
55 | 58 | const statements: ts.Statement[] = [];
|
56 | 59 |
|
| 60 | + statements.push(importDeclaration(["Route", "StatusCode"], "@code4rena/typed-client", true)); |
| 61 | + |
57 | 62 | for (const route of server.table()) {
|
58 | 63 | const routeName = getRouteName(route);
|
59 | 64 | const routeOptions: Record<string, { name: string; required: boolean }> = {};
|
@@ -108,40 +113,62 @@ export function generateClientType(server: Server) {
|
108 | 113 | statements.push(typeAliasDeclaration(payloadTypeName, generateType(route.settings.validate.payload as ExtendedObjectSchema), true));
|
109 | 114 | }
|
110 | 115 |
|
| 116 | + // the result type is the type of the actual response from the api |
| 117 | + // const resultTypeName = getTypeName(routeName, "result"); |
| 118 | + // the response type is the result wrapped in the higher level object including url, status, etc. |
111 | 119 | 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(); |
| 120 | + const matchedCodes: string[] = []; |
| 121 | + const responseTypeList: string[] = []; |
| 122 | + // first, we iterate our response validators and get everything that has a specific response code |
| 123 | + for (const [code, schema] of Object.entries(route.settings.response?.status ?? {})) { |
| 124 | + matchedCodes.push(code); |
| 125 | + |
| 126 | + const responseCodeTypeName = getTypeName(responseTypeName, code); |
| 127 | + const responseNode = typeLiteralNode({ |
| 128 | + status: { name: `${code}`, required: true }, |
| 129 | + ok: { name: (+code >= 200 && +code < 300) ? "true" : "false", required: true }, |
| 130 | + headers: { name: "Headers", required: true }, |
| 131 | + url: { name: "string", required: true }, |
| 132 | + data: { node: generateType(schema as ExtendedObjectSchema), required: isRequired(schema as ExtendedSchema) }, |
| 133 | + }); |
| 134 | + statements.push(typeAliasDeclaration(responseCodeTypeName, responseNode, true)); |
| 135 | + responseTypeList.push(responseCodeTypeName); |
| 136 | + } |
| 137 | + |
| 138 | + // now we insert a final response type where the status code is Exclude<StatusCodes, matchedCodes> |
| 139 | + const unknownResponseName = getTypeName(responseTypeName, "Unknown"); |
| 140 | + const unknownResponseNode = typeLiteralNode({ |
| 141 | + status: { |
| 142 | + name: matchedCodes.length |
| 143 | + // HACK: this could probably build a real node with a real union passed to a real generic |
| 144 | + ? `Exclude<StatusCode, ${matchedCodes.join(" | ")}>` |
| 145 | + : "StatusCode", |
| 146 | + required: true, |
| 147 | + }, |
| 148 | + ok: { name: "boolean", required: true }, |
| 149 | + headers: { name: "Headers", required: true }, |
| 150 | + url: { name: "string", required: true }, |
| 151 | + data: { name: "unknown", required: false }, |
| 152 | + }); |
| 153 | + statements.push(typeAliasDeclaration(unknownResponseName, unknownResponseNode, true)); |
| 154 | + responseTypeList.push(unknownResponseName); |
| 155 | + |
| 156 | + const responseUnionType = unionTypeNode(responseTypeList.map((responseType) => typeReferenceNode(responseType))); |
| 157 | + statements.push(typeAliasDeclaration(responseTypeName, responseUnionType, true)); |
| 158 | + |
115 | 159 | routeOptions.response = {
|
116 | 160 | name: responseTypeName,
|
117 | 161 | required: true,
|
118 | 162 | };
|
119 |
| - statements.push(typeAliasDeclaration(responseTypeName, generateType(responseValidator as ExtendedObjectSchema), true)); |
120 | 163 |
|
121 |
| - routeMap[route.method.toUpperCase()] ??= {}; |
122 |
| - routeMap[route.method.toUpperCase()][route.path] = routeOptions; |
| 164 | + const routeType = typeReferenceNode("Route", route.method.toUpperCase(), route.path, typeLiteralNode(routeOptions)); |
| 165 | + routeList.push(routeType); |
123 | 166 | }
|
124 | 167 |
|
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 |
| - return f.createPropertySignature( |
132 |
| - [], |
133 |
| - f.createStringLiteral(path), |
134 |
| - undefined, |
135 |
| - typeLiteralNode(options), |
136 |
| - ); |
137 |
| - }))); |
138 |
| - })); |
139 |
| - |
140 |
| - statements.push(typeAliasDeclaration("RouteMap", clientTypeNode, true)); |
141 |
| - |
142 |
| - addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/indent ", true); |
143 |
| - addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/quote-props ", true); |
144 |
| - addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable @stylistic/comma-dangle ", true); |
| 168 | + statements.push(typeAliasDeclaration("Routes", unionTypeNode(routeList), true)); |
| 169 | + |
| 170 | + // throw a comment at the front to disable eslint for the file since it may not align with formatting |
| 171 | + addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable ", true); |
145 | 172 |
|
146 | 173 | const sourceFile = factory.createSourceFile(statements, factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None);
|
147 | 174 | const printer = createPrinter();
|
|
0 commit comments