-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
185 lines (158 loc) · 7.42 KB
/
index.ts
File metadata and controls
185 lines (158 loc) · 7.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
import type { RequestRoute, Server } from "@hapi/hapi";
import Joi from "joi";
import type ts from "typescript";
import { addSyntheticLeadingComment } from "typescript";
import type { ExtendedObjectSchema, ExtendedSchema } from "./joi.ts";
import {
importDeclaration,
typeAliasDeclaration,
typeLiteralNode,
typeReferenceNode,
unionTypeNode,
} from "./nodes.ts";
import {
generateType,
} from "./types.ts";
import { createPrinter, EmitHint, factory, NodeFlags, SyntaxKind } from "typescript";
/** Generates a friendly name from a route
* This function uses the manually specified `id` if one is provided
* If not, a name is generated automatically using the route's method
* and path.
*
* GET /foo/bar -> getFooBar
* POST /users/@me/tax-status -> postUsersSelfTaxStatus
*/
export function getRouteName(route: RequestRoute): string {
if (route.settings.id) {
return route.settings.id;
}
const segments = route.path.split("/")
.filter((segment) => segment) // && !segment.startsWith("{"))
.map((segment) => {
if (segment === "@me") {
segment = "self";
}
if (segment.startsWith("{")) {
segment = camelCase(segment.slice(1, -1));
} else {
segment = segment.replace(/-([^-])/g, (_, p: string) => p.toUpperCase());
}
return [segment[0].toUpperCase(), ...segment.slice(1)].join("");
});
const name = `${route.method.toLowerCase()}${segments.join("")}`;
return name;
}
export function getTypeName(routeName: string, suffix: string): string {
return `${routeName[0].toUpperCase()}${routeName.slice(1)}${suffix[0].toUpperCase()}${suffix.slice(1)}`;
}
export function generateClientType(server: Server) {
const routeList: ts.TypeNode[] = [];
const statements: ts.Statement[] = [];
statements.push(importDeclaration(["Route", "StatusCode"], "@code4rena/typed-client", true));
for (const route of server.table()) {
const routeName = getRouteName(route);
const routeOptions: Record<string, { name: string; required: boolean }> = {};
const paramNames: string[] = (route.path.match(/{[^}]+}/g) ?? []).map((name: string) => name.slice(1, -1));
if (paramNames.length) {
// map parameter names to validators, use strings by default
const paramValidators: Record<string, ExtendedSchema> = paramNames.reduce((result, param) => {
return { ...result, [param]: Joi.string() };
}, {});
// if more specific param validators are specified, use those to override
if (route.settings.validate?.params) {
const params = route.settings.validate.params as ExtendedObjectSchema;
for (const term of params.$_terms.keys ?? []) {
paramValidators[term.key] = term.schema.required() as ExtendedSchema;
}
}
const paramsTypeName = getTypeName(routeName, "params");
routeOptions.params = {
name: paramsTypeName,
required: true,
};
statements.push(typeAliasDeclaration(paramsTypeName, generateType(Joi.object(paramValidators) as ExtendedObjectSchema), true));
}
if (route.settings.validate?.headers) {
const headersTypeName = getTypeName(routeName, "headers");
routeOptions.headers = {
name: headersTypeName,
required: isRequired(route.settings.validate.headers as ExtendedSchema),
};
statements.push(typeAliasDeclaration(headersTypeName, generateType(route.settings.validate.headers as ExtendedObjectSchema), true));
}
if (route.settings.validate?.query) {
const queryTypeName = getTypeName(routeName, "query");
routeOptions.query = {
name: queryTypeName,
required: isRequired(route.settings.validate.query as ExtendedSchema),
};
statements.push(typeAliasDeclaration(queryTypeName, generateType(route.settings.validate.query as ExtendedObjectSchema), true));
}
if (route.settings.validate?.payload) {
const payloadTypeName = getTypeName(routeName, "payload");
routeOptions.payload = {
name: payloadTypeName,
required: isRequired(route.settings.validate.payload as ExtendedSchema),
};
statements.push(typeAliasDeclaration(payloadTypeName, generateType(route.settings.validate.payload as ExtendedObjectSchema), true));
}
// the result type is the type of the actual response from the api
// const resultTypeName = getTypeName(routeName, "result");
// the response type is the result wrapped in the higher level object including url, status, etc.
const responseTypeName = getTypeName(routeName, "response");
const matchedCodes: string[] = [];
const responseTypeList: string[] = [];
// first, we iterate our response validators and get everything that has a specific response code
for (const [code, schema] of Object.entries(route.settings.response?.status ?? {})) {
matchedCodes.push(code);
const responseCodeTypeName = getTypeName(responseTypeName, code);
const responseNode = typeLiteralNode({
status: { name: `${code}`, required: true },
ok: { name: (+code >= 200 && +code < 300) ? "true" : "false", required: true },
headers: { name: "Headers", required: true },
url: { name: "string", required: true },
data: { node: generateType(schema as ExtendedObjectSchema), required: isRequired(schema as ExtendedSchema) },
});
statements.push(typeAliasDeclaration(responseCodeTypeName, responseNode, true));
responseTypeList.push(responseCodeTypeName);
}
// now we insert a final response type where the status code is Exclude<StatusCodes, matchedCodes>
const unknownResponseName = getTypeName(responseTypeName, "Unknown");
const unknownResponseNode = typeLiteralNode({
status: {
name: matchedCodes.length
// HACK: this could probably build a real node with a real union passed to a real generic
? `Exclude<StatusCode, ${matchedCodes.join(" | ")}>`
: "StatusCode",
required: true,
},
ok: { name: "boolean", required: true },
headers: { name: "Headers", required: true },
url: { name: "string", required: true },
data: { name: "unknown", required: false },
});
statements.push(typeAliasDeclaration(unknownResponseName, unknownResponseNode, true));
responseTypeList.push(unknownResponseName);
const responseUnionType = unionTypeNode(responseTypeList.map((responseType) => typeReferenceNode(responseType)));
statements.push(typeAliasDeclaration(responseTypeName, responseUnionType, true));
routeOptions.response = {
name: responseTypeName,
required: true,
};
const routeType = typeReferenceNode("Route", route.method.toUpperCase(), route.path, typeLiteralNode(routeOptions));
routeList.push(routeType);
}
statements.push(typeAliasDeclaration("Routes", unionTypeNode(routeList), true));
// throw a comment at the front to disable eslint for the file since it may not align with formatting
addSyntheticLeadingComment(statements[0], SyntaxKind.MultiLineCommentTrivia, " eslint-disable ", true);
const sourceFile = factory.createSourceFile(statements, factory.createToken(SyntaxKind.EndOfFileToken), NodeFlags.None);
const printer = createPrinter();
const clientType = printer.printNode(EmitHint.SourceFile, sourceFile, sourceFile);
return clientType;
}
function isRequired(schema: ExtendedSchema) {
return schema._flags.presence === "required";
}
function camelCase(value: string) {
return value.replace(/_(.)/g, (_, p: string) => p.toUpperCase());
}