Skip to content

Commit 9065bd6

Browse files
committed
initial commit
0 parents  commit 9065bd6

File tree

11 files changed

+1261
-0
lines changed

11 files changed

+1261
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
node_modules

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
package-lock=false

eslint.config.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { globalIgnores } from "eslint/config";
2+
import eslint from "@eslint/js";
3+
import tseslint from "typescript-eslint";
4+
import stylistic from "@stylistic/eslint-plugin";
5+
6+
export default tseslint.config(
7+
globalIgnores(["tap-snapshots/**"]),
8+
eslint.configs.recommended,
9+
...tseslint.configs.recommendedTypeChecked,
10+
...tseslint.configs.strict,
11+
stylistic.configs.customize({
12+
quotes: "double",
13+
semi: true,
14+
arrowParens: true,
15+
braceStyle: "1tbs",
16+
}),
17+
{
18+
rules: {
19+
// emulate how typescript deals with unused vars
20+
"@typescript-eslint/no-unused-vars": ["error", {
21+
args: "all",
22+
argsIgnorePattern: "^_",
23+
caughtErrors: "all",
24+
caughtErrorsIgnorePattern: "^_",
25+
destructuredArrayIgnorePattern: "^_",
26+
varsIgnorePattern: "^_",
27+
ignoreRestSiblings: true,
28+
}],
29+
"@stylistic/max-len": ["error", {
30+
code: 140,
31+
ignoreStrings: true,
32+
ignoreTemplateLiterals: true,
33+
ignoreUrls: true,
34+
}],
35+
"@stylistic/object-curly-newline": ["error", {
36+
multiline: true,
37+
consistent: true,
38+
}],
39+
"@typescript-eslint/consistent-type-imports": ["error", {
40+
fixStyle: "separate-type-imports",
41+
prefer: "type-imports",
42+
}],
43+
},
44+
languageOptions: {
45+
parserOptions: {
46+
projectService: true,
47+
tsconfigRootDir: import.meta.dirname,
48+
},
49+
},
50+
},
51+
{
52+
// allow non-null assertions in tests because they can be useful for more concise tests
53+
files: ["test/**/*"],
54+
rules: {
55+
"@typescript-eslint/no-non-null-assertion": "off",
56+
"@typescript-eslint/ban-ts-comment": "off",
57+
"@typescript-eslint/consistent-type-imports": ["error", {
58+
disallowTypeAnnotations: false,
59+
fixStyle: "separate-type-imports",
60+
prefer: "type-imports",
61+
}],
62+
},
63+
},
64+
);

lib/build/index.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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+
}

lib/build/joi.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import type {
2+
AlternativesSchema,
3+
AnySchema,
4+
ArraySchema,
5+
BooleanSchema,
6+
NumberSchema,
7+
ObjectSchema,
8+
Reference,
9+
Schema,
10+
StringSchema,
11+
} from "joi";
12+
13+
export type ExtendedSchema = Schema & {
14+
_singleRules: Map<string, {
15+
name: string;
16+
method: string;
17+
args: { limit: number } | undefined;
18+
}>;
19+
_valids: null | {
20+
_values: Set<unknown>;
21+
};
22+
_flags: Schema["_flags"] & {
23+
only?: boolean;
24+
single?: boolean;
25+
presence?: "optional" | "required" | "forbidden";
26+
_endedSwitch?: boolean;
27+
};
28+
};
29+
30+
export type Dependency = {
31+
rel: string;
32+
paths: string[];
33+
};
34+
35+
export type AlternativeReference = {
36+
ref?: Reference;
37+
is?: Schema;
38+
then?: Schema;
39+
otherwise?: Schema;
40+
};
41+
42+
export type ExtendedAlternativesSchema = AlternativesSchema & ExtendedSchema & {
43+
$_terms: {
44+
matches: ({
45+
key: string;
46+
ref?: Reference;
47+
schema?: Schema;
48+
is?: Schema;
49+
not?: Schema;
50+
then?: Schema;
51+
otherwise?: Schema;
52+
switch?: ({
53+
is?: Schema;
54+
then?: Schema;
55+
otherwise?: Schema;
56+
})[];
57+
})[];
58+
};
59+
};
60+
61+
export type ExtendedAnySchema = AnySchema & ExtendedSchema;
62+
63+
export type ExtendedArraySchema = ArraySchema & ExtendedSchema & {
64+
$_terms: {
65+
items: Schema[];
66+
};
67+
};
68+
69+
export type ExtendedBooleanSchema = BooleanSchema & ExtendedSchema;
70+
71+
export type ExtendedNumberSchema = NumberSchema & ExtendedSchema;
72+
73+
export type ExtendedObjectSchema = ObjectSchema & ExtendedSchema & {
74+
$_terms: {
75+
keys?: ({
76+
key: string;
77+
schema: Schema;
78+
})[];
79+
dependencies?: Dependency[];
80+
};
81+
};
82+
83+
export type ExtendedStringSchema = StringSchema & ExtendedSchema;
84+
85+
export function isForbidden(schema: Schema) {
86+
return schema._flags.presence === "forbidden";
87+
}
88+
89+
export function isRequired(schema: Schema) {
90+
return schema._flags.presence === "required";
91+
}

0 commit comments

Comments
 (0)