Skip to content

Commit 21c9b71

Browse files
committed
feat(code-gen): add stricter validation on R.params() and R.query()
Params where quite strict already. Just making sure to define the behavior on nested objects, arrays, etc which was undefined behavior up until now.
1 parent 41e3e3f commit 21c9b71

File tree

4 files changed

+241
-0
lines changed

4 files changed

+241
-0
lines changed

packages/code-gen/src/generate.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import { objectExpansionExecute } from "./processors/object-expansion.js";
4343
import { routeInvalidationsCheck } from "./processors/route-invalidation.js";
4444
import { routeStructureCreate } from "./processors/route-structure.js";
4545
import { routeTrieBuild } from "./processors/route-trie.js";
46+
import { routeValidation } from "./processors/route-validation.js";
4647
import { structureNameChecks } from "./processors/structure-name-checks.js";
4748
import {
4849
structureCopyAndSort,
@@ -141,6 +142,7 @@ export function generateExecute(generator, options) {
141142
crudValidation(generateContext);
142143
crudTypesCreate(generateContext);
143144

145+
routeValidation(generateContext);
144146
routeInvalidationsCheck(generateContext);
145147
routeStructureCreate(generateContext);
146148
routeTrieBuild(generateContext);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Various route validation related things
3+
*
4+
* @param {import("../generate").GenerateContext} generateContext
5+
*/
6+
export function routeValidation(
7+
generateContext: import("../generate").GenerateContext,
8+
): void;
9+
//# sourceMappingURL=route-validation.d.ts.map
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { AppError, isNil } from "@compas/stdlib";
2+
import {
3+
errorsThrowCombinedError,
4+
stringFormatNameForError,
5+
} from "../utils.js";
6+
import { structureRoutes } from "./routes.js";
7+
import { structureResolveReference } from "./structure.js";
8+
import { typeDefinitionTraverse } from "./type-definition-traverse.js";
9+
10+
/**
11+
* Various route validation related things
12+
*
13+
* @param {import("../generate").GenerateContext} generateContext
14+
*/
15+
export function routeValidation(generateContext) {
16+
/** @type {import("@compas/stdlib").AppError[]} */
17+
const errors = [];
18+
19+
for (const route of structureRoutes(generateContext)) {
20+
try {
21+
routeValidationSimpleQueryAndParamTypes(generateContext, route, "params");
22+
routeValidationSimpleQueryAndParamTypes(generateContext, route, "query");
23+
} catch (/** @type {any} */ e) {
24+
errors.push(e);
25+
}
26+
}
27+
28+
errorsThrowCombinedError(errors);
29+
}
30+
31+
/**
32+
* @param {import("../generate").GenerateContext} generateContext
33+
* @param {import("../../types/advanced-types").NamedType<import("../generated/common/types").StructureRouteDefinition>} route
34+
* @param {"query"|"params"} subType
35+
*/
36+
function routeValidationSimpleQueryAndParamTypes(
37+
generateContext,
38+
route,
39+
subType,
40+
) {
41+
if (isNil(route[subType])) {
42+
return;
43+
}
44+
45+
const resolvedType = structureResolveReference(
46+
generateContext.structure,
47+
48+
// @ts-expect-error
49+
route[subType],
50+
);
51+
52+
if (resolvedType.type !== "object") {
53+
throw AppError.serverError({
54+
message: `${stringFormatNameForError(route)} ${subType} is a '${
55+
resolvedType.type
56+
}'. Only 'T.object()' or a reference to an object is allowed.`,
57+
});
58+
}
59+
60+
const allowedTypes = [
61+
"any",
62+
"anyOf",
63+
"boolean",
64+
"date",
65+
"number",
66+
"reference",
67+
"string",
68+
"uuid",
69+
];
70+
71+
for (const key of Object.keys(resolvedType.keys ?? {})) {
72+
routeValidationConformAllowedTypes(
73+
generateContext,
74+
route,
75+
resolvedType.keys[key],
76+
allowedTypes,
77+
new Set(),
78+
);
79+
}
80+
}
81+
82+
/**
83+
* Recursively check if field only uses 'allowedTypes'.
84+
*
85+
* @param {import("../generate").GenerateContext} generateContext
86+
* @param {import("../../types/advanced-types").NamedType<import("../generated/common/types").StructureRouteDefinition>} route
87+
* @param {import("../generated/common/types.js").StructureTypeSystemDefinition} field
88+
* @param {string[]} allowedTypes
89+
* @param {Set<any>} handledRefs
90+
*/
91+
function routeValidationConformAllowedTypes(
92+
generateContext,
93+
route,
94+
field,
95+
allowedTypes,
96+
handledRefs,
97+
) {
98+
typeDefinitionTraverse(
99+
field,
100+
(type) => {
101+
if (!allowedTypes.includes(type.type)) {
102+
throw AppError.serverError({
103+
message: `Found an invalid type '${
104+
type.type
105+
}' used in the params or query of ${stringFormatNameForError(route)}`,
106+
});
107+
}
108+
109+
if (type.type === "reference") {
110+
const resolvedRef = structureResolveReference(
111+
generateContext.structure,
112+
type,
113+
);
114+
115+
if (!handledRefs.has(resolvedRef)) {
116+
handledRefs.add(resolvedRef);
117+
118+
routeValidationConformAllowedTypes(
119+
generateContext,
120+
route,
121+
122+
// @ts-expect-error
123+
resolvedRef,
124+
allowedTypes,
125+
handledRefs,
126+
);
127+
}
128+
}
129+
},
130+
{
131+
isInitialType: true,
132+
},
133+
);
134+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { mainTestFn, test } from "@compas/cli";
2+
import {
3+
testGeneratorError,
4+
testGeneratorStaticFiles,
5+
} from "../../test/testing.js";
6+
7+
mainTestFn(import.meta);
8+
9+
test("code-gen/processors/route-validation", (t) => {
10+
t.test("query validation - fail top level", (t) => {
11+
testGeneratorError(
12+
t,
13+
{
14+
partialError:
15+
"Only 'T.object()' or a reference to an object is allowed",
16+
},
17+
(T) => {
18+
const R = T.router("/");
19+
20+
return [R.get("/").query([true])];
21+
},
22+
);
23+
});
24+
25+
t.test("param validation - fail nested", (t) => {
26+
testGeneratorError(
27+
t,
28+
{
29+
partialError:
30+
"Found an invalid type 'array' used in the params or query of ('app', 'get')",
31+
},
32+
(T) => {
33+
const R = T.router("/");
34+
35+
return [
36+
R.get("/:foo").params({
37+
foo: [true],
38+
}),
39+
];
40+
},
41+
);
42+
});
43+
44+
t.test("query validation - fail nested reference", (t) => {
45+
testGeneratorError(
46+
t,
47+
{
48+
partialError: "Found an invalid type 'object' used",
49+
},
50+
(T) => {
51+
const R = T.router("/");
52+
53+
return [
54+
R.get("/").query({
55+
obj: T.object("query").keys({}),
56+
}),
57+
];
58+
},
59+
);
60+
});
61+
62+
t.test("route validation - success", (t) => {
63+
testGeneratorStaticFiles(t, {}, (T) => {
64+
const R = T.router("/");
65+
66+
return [
67+
R.get("/none", "none"),
68+
R.get("/empty", "empty").params({}).query({}),
69+
70+
R.get("/bool/:foo", "paramBoolean").params({
71+
foo: T.bool(),
72+
}),
73+
R.get("/date/:foo", "paramDate").params({
74+
foo: T.date(),
75+
}),
76+
R.get("/number/:foo", "paramNumber").params({
77+
foo: T.number(),
78+
}),
79+
R.get("/string/:foo", "paramString").params({
80+
foo: T.string(),
81+
}),
82+
R.get("/uuid/:foo", "paramUuid").params({
83+
foo: T.uuid(),
84+
}),
85+
86+
R.get("/query", "queryParams").query({
87+
foo: T.bool("namedBool"),
88+
date: T.date(),
89+
str: T.string(),
90+
}),
91+
];
92+
});
93+
94+
t.pass();
95+
});
96+
});

0 commit comments

Comments
 (0)