Skip to content

Commit e771e63

Browse files
Enhances query validation for protected routes
Introduces query validation using Zod schema to standardize and ensure accurate processing of query parameters in protected loaders and actions. This improvement increases the robustness of query handling by validating parameters such as pagination and sorting criteria. Refines the loader function to handle queries more efficiently, leveraging Zod to enforce value constraints and defaults, thereby reducing potential runtime errors from malformed queries.
1 parent 3c4118c commit e771e63

File tree

2 files changed

+80
-30
lines changed

2 files changed

+80
-30
lines changed

app/features/admin/routes/users.tsx

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,24 @@ import { Prisma } from "@prisma/client";
99
import { Can } from "~/components/providers/permission.provider";
1010
import { Button } from "~/components/ui/button";
1111
import { createProtectedLoader } from "~/lib/secureRoute";
12+
import { z } from "zod";
1213

1314
export const loader = createProtectedLoader({
14-
function: async ({ request }) => {
15-
const url = new URL(request.url);
16-
const page = parseInt(url.searchParams.get("page") || "1");
17-
const limit = parseInt(url.searchParams.get("limit") || "10");
18-
const search = url.searchParams.get("search") || "";
19-
const sortBy = url.searchParams.get("sortBy") || "createdAt";
20-
const sortDirection = (url.searchParams.get("sortDirection") || "desc") as "asc" | "desc";
21-
15+
queryValidation: z.object({
16+
page: z.number().min(1).default(1),
17+
limit: z.number().min(1).default(10),
18+
search: z.string().optional(),
19+
sortBy: z.string().optional().default("createdAt"),
20+
sortDirection: z.enum(["asc", "desc"]).optional(),
21+
}),
22+
function: async ({ query }) => {
2223
// Build the where clause for searching
23-
const where = search
24+
const where = query.search
2425
? {
2526
OR: [
26-
{ name: { contains: search, mode: Prisma.QueryMode.insensitive } },
27-
{ email: { contains: search, mode: Prisma.QueryMode.insensitive } },
28-
{ role: { contains: search, mode: Prisma.QueryMode.insensitive } },
27+
{ name: { contains: query.search, mode: Prisma.QueryMode.insensitive } },
28+
{ email: { contains: query.search, mode: Prisma.QueryMode.insensitive } },
29+
{ role: { contains: query.search, mode: Prisma.QueryMode.insensitive } },
2930
],
3031
}
3132
: {};
@@ -37,10 +38,10 @@ export const loader = createProtectedLoader({
3738
const users = await prisma.user.findMany({
3839
where,
3940
orderBy: {
40-
[sortBy]: sortDirection,
41+
[query.sortBy]: query.sortDirection,
4142
},
42-
skip: (page - 1) * limit,
43-
take: limit,
43+
skip: (query.page - 1) * query.limit,
44+
take: query.limit,
4445
});
4546

4647
return {
@@ -49,10 +50,10 @@ export const loader = createProtectedLoader({
4950
role: user.role ? user.role.split(",").map((role) => role.trim()) : null,
5051
})),
5152
total,
52-
page,
53-
limit,
54-
sortBy,
55-
sortDirection,
53+
page: query.page,
54+
limit: query.limit,
55+
sortBy: query.sortBy,
56+
sortDirection: query.sortDirection,
5657
};
5758
},
5859
});

app/lib/secureRoute.ts

Lines changed: 60 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,19 @@ type StrictParams<T> = {
1818
type Identity = Awaited<ReturnType<typeof getUserInformation>>;
1919

2020
// Base configuration type for protected routes
21-
type BaseProtectedConfig<T, P extends z.ZodSchema | undefined = undefined> = {
21+
type BaseProtectedConfig<
22+
T,
23+
P extends z.ZodSchema | undefined = undefined,
24+
Q extends z.ZodSchema | undefined = undefined,
25+
> = {
2226
permissions?: PermissionCheck;
2327
paramValidation?: P;
28+
queryValidation?: Q;
2429
function: (
2530
args: Omit<LoaderFunctionArgs, "params"> & {
2631
identity: Identity;
2732
params: P extends z.ZodSchema ? StrictParams<z.infer<P>> : null;
33+
query: Q extends z.ZodSchema ? StrictParams<z.infer<Q>> : null;
2834
}
2935
) => T;
3036
};
@@ -33,39 +39,63 @@ type BaseProtectedConfig<T, P extends z.ZodSchema | undefined = undefined> = {
3339
type ProtectedLoaderConfig<
3440
T = any,
3541
P extends z.ZodSchema | undefined = undefined,
36-
> = BaseProtectedConfig<T, P>;
42+
Q extends z.ZodSchema | undefined = undefined,
43+
> = BaseProtectedConfig<T, P, Q>;
3744

3845
// Action specific configuration
3946
type ProtectedActionConfig<
4047
T = any,
4148
P extends z.ZodSchema | undefined = undefined,
42-
> = BaseProtectedConfig<T, P>;
49+
Q extends z.ZodSchema | undefined = undefined,
50+
> = BaseProtectedConfig<T, P, Q>;
4351

4452
/**
4553
* Creates a protected loader function that validates permissions and parameters
4654
*/
47-
export function createProtectedLoader<T, P extends z.ZodSchema | undefined = undefined>(
48-
config: ProtectedLoaderConfig<T, P>
49-
) {
55+
export function createProtectedLoader<
56+
T,
57+
P extends z.ZodSchema | undefined = undefined,
58+
Q extends z.ZodSchema | undefined = undefined,
59+
>(config: ProtectedLoaderConfig<T, P, Q>) {
5060
return async (args: LoaderFunctionArgs) => {
5161
const parsedParams = validateParams(args.params, config.paramValidation);
62+
const parsedQuery = validateQueryParams(
63+
new URL(args.request.url).searchParams,
64+
config.queryValidation
65+
);
5266
const identity = await validateIdentity(args.request, config.permissions);
5367
const { params: _, ...rest } = args;
54-
return await config.function({ ...rest, identity, params: parsedParams.data });
68+
return await config.function({
69+
...rest,
70+
identity,
71+
params: parsedParams.data,
72+
query: parsedQuery.data,
73+
});
5574
};
5675
}
5776

5877
/**
5978
* Creates a protected action function that validates permissions and parameters
6079
*/
61-
export function createProtectedAction<T, P extends z.ZodSchema | undefined = undefined>(
62-
config: ProtectedActionConfig<T, P>
63-
) {
80+
export function createProtectedAction<
81+
T,
82+
P extends z.ZodSchema | undefined = undefined,
83+
Q extends z.ZodSchema | undefined = undefined,
84+
>(config: ProtectedActionConfig<T, P, Q>) {
6485
return async (args: ActionFunctionArgs) => {
6586
const parsedParams = validateParams(args.params, config.paramValidation);
87+
const parsedQuery = validateQueryParams(
88+
new URL(args.request.url).searchParams,
89+
config.queryValidation
90+
);
6691
const identity = await validateIdentity(args.request, config.permissions);
6792
const { params: _, ...rest } = args;
68-
return await config.function({ ...rest, identity, params: parsedParams.data });
93+
return await config.function({
94+
...rest,
95+
identity,
96+
params: parsedParams.data,
97+
query: parsedQuery.data,
98+
});
6999
};
70100
}
71101

@@ -93,3 +123,22 @@ function validateParams(params: any, schema?: z.ZodSchema) {
93123

94124
return { data: parsed.data };
95125
}
126+
127+
function validateQueryParams(searchParams: URLSearchParams, schema?: z.ZodSchema) {
128+
if (!schema) {
129+
return { data: null };
130+
}
131+
132+
// Convert URLSearchParams to a plain object
133+
const params: Record<string, string> = {};
134+
searchParams.forEach((value, key) => {
135+
params[key] = value;
136+
});
137+
138+
const parsed = schema.safeParse(params);
139+
if (!parsed.success) {
140+
throw new Error(parsed.error.message);
141+
}
142+
143+
return { data: parsed.data };
144+
}

0 commit comments

Comments
 (0)