mirror of
https://github.com/versia-pub/server.git
synced 2025-12-07 16:58:20 +01:00
refactor(api): 🎨 Refactor request parser
This commit is contained in:
parent
3247e90131
commit
1b7b71eaec
|
|
@ -16,19 +16,24 @@ export class RequestParser {
|
||||||
* @throws Error if body is invalid
|
* @throws Error if body is invalid
|
||||||
*/
|
*/
|
||||||
async toObject<T>() {
|
async toObject<T>() {
|
||||||
try {
|
switch (await this.determineContentType()) {
|
||||||
switch (await this.determineContentType()) {
|
case "application/json":
|
||||||
case "application/json":
|
return {
|
||||||
return this.parseJson<T>();
|
...(await this.parseJson<T>()),
|
||||||
case "application/x-www-form-urlencoded":
|
...this.parseQuery<T>(),
|
||||||
return this.parseFormUrlencoded<T>();
|
};
|
||||||
case "multipart/form-data":
|
case "application/x-www-form-urlencoded":
|
||||||
return this.parseFormData<T>();
|
return {
|
||||||
default:
|
...(await this.parseFormUrlencoded<T>()),
|
||||||
return this.parseQuery<T>();
|
...this.parseQuery<T>(),
|
||||||
}
|
};
|
||||||
} catch {
|
case "multipart/form-data":
|
||||||
return {} as T;
|
return {
|
||||||
|
...(await this.parseFormData<T>()),
|
||||||
|
...this.parseQuery<T>(),
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return { ...this.parseQuery() } as T;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -57,7 +62,7 @@ export class RequestParser {
|
||||||
|
|
||||||
// Check if body is valid JSON
|
// Check if body is valid JSON
|
||||||
try {
|
try {
|
||||||
await this.request.json();
|
await this.request.clone().json();
|
||||||
return "application/json";
|
return "application/json";
|
||||||
} catch {
|
} catch {
|
||||||
// This is not JSON
|
// This is not JSON
|
||||||
|
|
@ -65,7 +70,7 @@ export class RequestParser {
|
||||||
|
|
||||||
// Check if body is valid FormData
|
// Check if body is valid FormData
|
||||||
try {
|
try {
|
||||||
await this.request.formData();
|
await this.request.clone().formData();
|
||||||
return "multipart/form-data";
|
return "multipart/form-data";
|
||||||
} catch {
|
} catch {
|
||||||
// This is not FormData
|
// This is not FormData
|
||||||
|
|
@ -90,44 +95,38 @@ export class RequestParser {
|
||||||
* @throws Error if body is invalid
|
* @throws Error if body is invalid
|
||||||
*/
|
*/
|
||||||
private async parseFormData<T>(): Promise<Partial<T>> {
|
private async parseFormData<T>(): Promise<Partial<T>> {
|
||||||
const formData = await this.request.formData();
|
const formData = await this.request.clone().formData();
|
||||||
const result: Partial<T> = {};
|
const result: Partial<T> = {};
|
||||||
|
|
||||||
// Check if there are any files in the FormData
|
// Extract the files from the FormData
|
||||||
if (
|
for (const [key, value] of formData.entries()) {
|
||||||
Array.from(formData.values()).some((value) => value instanceof Blob)
|
if (value instanceof Blob) {
|
||||||
) {
|
result[key as keyof T] = value as T[keyof T];
|
||||||
for (const [key, value] of formData.entries()) {
|
|
||||||
if (value instanceof Blob) {
|
|
||||||
result[key as keyof T] = value as T[keyof T];
|
|
||||||
} else if (key.endsWith("[]")) {
|
|
||||||
const arrayKey = key.slice(0, -2) as keyof T;
|
|
||||||
if (!result[arrayKey]) {
|
|
||||||
result[arrayKey] = [] as T[keyof T];
|
|
||||||
}
|
|
||||||
|
|
||||||
(result[arrayKey] as FormDataEntryValue[]).push(value);
|
|
||||||
} else {
|
|
||||||
result[key as keyof T] = value as T[keyof T];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Convert to URLSearchParams and parse as query
|
|
||||||
const searchParams = new URLSearchParams([
|
|
||||||
...formData.entries(),
|
|
||||||
] as [string, string][]);
|
|
||||||
|
|
||||||
const parsed = parse(searchParams.toString(), {
|
|
||||||
parseArrays: true,
|
|
||||||
interpretNumericEntities: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return castBooleanObject(
|
|
||||||
parsed as PossiblyRecursiveObject,
|
|
||||||
) as Partial<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
const formDataWithoutFiles = new FormData();
|
||||||
|
for (const [key, value] of formData.entries()) {
|
||||||
|
if (!(value instanceof Blob)) {
|
||||||
|
formDataWithoutFiles.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to URLSearchParams and parse as query
|
||||||
|
const searchParams = new URLSearchParams([
|
||||||
|
...formDataWithoutFiles.entries(),
|
||||||
|
] as [string, string][]);
|
||||||
|
|
||||||
|
const parsed = parse(searchParams.toString(), {
|
||||||
|
parseArrays: true,
|
||||||
|
interpretNumericEntities: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const casted = castBooleanObject(
|
||||||
|
parsed as PossiblyRecursiveObject,
|
||||||
|
) as Partial<T>;
|
||||||
|
|
||||||
|
return { ...result, ...casted };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -163,7 +162,7 @@ export class RequestParser {
|
||||||
* @throws Error if body is invalid
|
* @throws Error if body is invalid
|
||||||
* @returns JavaScript object of type T
|
* @returns JavaScript object of type T
|
||||||
*/
|
*/
|
||||||
private parseQuery<T>(): Partial<T> {
|
parseQuery<T>(): Partial<T> {
|
||||||
const parsed = parse(
|
const parsed = parse(
|
||||||
new URL(this.request.url).searchParams.toString(),
|
new URL(this.request.url).searchParams.toString(),
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ describe("RequestParser", () => {
|
||||||
const request = new Request(
|
const request = new Request(
|
||||||
"http://localhost?param1=value1¶m2=value2",
|
"http://localhost?param1=value1¶m2=value2",
|
||||||
);
|
);
|
||||||
const result = await new RequestParser(request).toObject<{
|
const result = await new RequestParser(request).parseQuery<{
|
||||||
param1: string;
|
param1: string;
|
||||||
param2: string;
|
param2: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
@ -18,30 +18,30 @@ describe("RequestParser", () => {
|
||||||
const request = new Request(
|
const request = new Request(
|
||||||
"http://localhost?test[]=value1&test[]=value2",
|
"http://localhost?test[]=value1&test[]=value2",
|
||||||
);
|
);
|
||||||
const result = await new RequestParser(request).toObject<{
|
const result = await new RequestParser(request).parseQuery<{
|
||||||
test: string[];
|
test: string[];
|
||||||
}>();
|
}>();
|
||||||
expect(result.test).toEqual(["value1", "value2"]);
|
expect(result?.test).toEqual(["value1", "value2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("With Array of objects", async () => {
|
test("With Array of objects", async () => {
|
||||||
const request = new Request(
|
const request = new Request(
|
||||||
"http://localhost?test[][key]=value1&test[][value]=value2",
|
"http://localhost?test[][key]=value1&test[][value]=value2",
|
||||||
);
|
);
|
||||||
const result = await new RequestParser(request).toObject<{
|
const result = await new RequestParser(request).parseQuery<{
|
||||||
test: { key: string; value: string }[];
|
test: { key: string; value: string }[];
|
||||||
}>();
|
}>();
|
||||||
expect(result.test).toEqual([{ key: "value1", value: "value2" }]);
|
expect(result?.test).toEqual([{ key: "value1", value: "value2" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("With Array of multiple objects", async () => {
|
test("With Array of multiple objects", async () => {
|
||||||
const request = new Request(
|
const request = new Request(
|
||||||
"http://localhost?test[][key]=value1&test[][value]=value2&test[][key]=value3&test[][value]=value4",
|
"http://localhost?test[][key]=value1&test[][value]=value2&test[][key]=value3&test[][value]=value4",
|
||||||
);
|
);
|
||||||
const result = await new RequestParser(request).toObject<{
|
const result = await new RequestParser(request).parseQuery<{
|
||||||
test: { key: string[]; value: string[] }[];
|
test: { key: string[]; value: string[] }[];
|
||||||
}>();
|
}>();
|
||||||
expect(result.test).toEqual([
|
expect(result?.test).toEqual([
|
||||||
{ key: ["value1", "value3"], value: ["value2", "value4"] },
|
{ key: ["value1", "value3"], value: ["value2", "value4"] },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -50,7 +50,7 @@ describe("RequestParser", () => {
|
||||||
const request = new Request(
|
const request = new Request(
|
||||||
"http://localhost?param1=value1¶m2=value2&test[]=value1&test[]=value2",
|
"http://localhost?param1=value1¶m2=value2&test[]=value1&test[]=value2",
|
||||||
);
|
);
|
||||||
const result = await new RequestParser(request).toObject<{
|
const result = await new RequestParser(request).parseQuery<{
|
||||||
param1: string;
|
param1: string;
|
||||||
param2: string;
|
param2: string;
|
||||||
test: string[];
|
test: string[];
|
||||||
|
|
@ -116,8 +116,8 @@ describe("RequestParser", () => {
|
||||||
const result = await new RequestParser(request).toObject<{
|
const result = await new RequestParser(request).toObject<{
|
||||||
file: File;
|
file: File;
|
||||||
}>();
|
}>();
|
||||||
expect(result.file).toBeInstanceOf(File);
|
expect(result?.file).toBeInstanceOf(File);
|
||||||
expect(await result.file?.text()).toEqual("content");
|
expect(await result?.file?.text()).toEqual("content");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("With Array", async () => {
|
test("With Array", async () => {
|
||||||
|
|
@ -131,7 +131,7 @@ describe("RequestParser", () => {
|
||||||
const result = await new RequestParser(request).toObject<{
|
const result = await new RequestParser(request).toObject<{
|
||||||
test: string[];
|
test: string[];
|
||||||
}>();
|
}>();
|
||||||
expect(result.test).toEqual(["value1", "value2"]);
|
expect(result?.test).toEqual(["value1", "value2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("With all three at once", async () => {
|
test("With all three at once", async () => {
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ export const processRoute = async (
|
||||||
const parsedRequest = await new RequestParser(request.clone())
|
const parsedRequest = await new RequestParser(request.clone())
|
||||||
.toObject()
|
.toObject()
|
||||||
.catch(async (err) => {
|
.catch(async (err) => {
|
||||||
|
console.log(err);
|
||||||
await logger.logError(
|
await logger.logError(
|
||||||
LogLevel.ERROR,
|
LogLevel.ERROR,
|
||||||
"Server.RouteRequestParser",
|
"Server.RouteRequestParser",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,31 @@ export const meta = applyConfig({
|
||||||
export const schema = z.object({
|
export const schema = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
password: z.string().min(2).max(100),
|
password: z.string().min(2).max(100),
|
||||||
|
scope: z.string().optional(),
|
||||||
|
redirect_uri: z.string().url().optional(),
|
||||||
|
response_type: z.enum([
|
||||||
|
"code",
|
||||||
|
"token",
|
||||||
|
"none",
|
||||||
|
"id_token",
|
||||||
|
"code id_token",
|
||||||
|
"code token",
|
||||||
|
"token id_token",
|
||||||
|
"code token id_token",
|
||||||
|
]),
|
||||||
|
client_id: z.string(),
|
||||||
|
state: z.string().optional(),
|
||||||
|
code_challenge: z.string().optional(),
|
||||||
|
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
||||||
|
prompt: z
|
||||||
|
.enum(["none", "login", "consent", "select_account"])
|
||||||
|
.optional()
|
||||||
|
.default("none"),
|
||||||
|
max_age: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.default(60 * 60 * 24 * 7),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const querySchema = z.object({
|
export const querySchema = z.object({
|
||||||
|
|
@ -91,22 +116,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
"Invalid email or password",
|
"Invalid email or password",
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsedQuery = await new RequestParser(
|
const { client_id } = extraData.parsedRequest;
|
||||||
new Request(req.url),
|
|
||||||
).toObject();
|
|
||||||
|
|
||||||
if (!parsedQuery) {
|
|
||||||
return errorResponse("Invalid query", 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsingResult = querySchema.safeParse(parsedQuery);
|
|
||||||
|
|
||||||
if (parsingResult && !parsingResult.success) {
|
|
||||||
// Return a 422 error with the first error message
|
|
||||||
return errorResponse(fromZodError(parsingResult.error).toString(), 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { client_id } = parsingResult.data;
|
|
||||||
|
|
||||||
// Try and import the key
|
// Try and import the key
|
||||||
const privateKey = await crypto.subtle.importKey(
|
const privateKey = await crypto.subtle.importKey(
|
||||||
|
|
@ -145,9 +155,10 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
|
||||||
if (application.website)
|
if (application.website)
|
||||||
searchParams.append("website", application.website);
|
searchParams.append("website", application.website);
|
||||||
|
|
||||||
// Add all data that is not undefined
|
// Add all data that is not undefined except email and password
|
||||||
for (const [key, value] of Object.entries(parsingResult.data)) {
|
for (const [key, value] of Object.entries(extraData.parsedRequest)) {
|
||||||
if (value !== undefined) searchParams.append(key, String(value));
|
if (key !== "email" && key !== "password" && value !== undefined)
|
||||||
|
searchParams.append(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to OAuth authorize with JWT
|
// Redirect to OAuth authorize with JWT
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,8 @@ describe("POST /api/auth/login/", () => {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log(await response.text());
|
||||||
|
|
||||||
expect(response.status).toBe(302);
|
expect(response.status).toBe(302);
|
||||||
expect(response.headers.get("location")).toBeDefined();
|
expect(response.headers.get("location")).toBeDefined();
|
||||||
const locationHeader = new URL(
|
const locationHeader = new URL(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue