From 6a44eac70c41bb1343a20ddf3ce775e416214d75 Mon Sep 17 00:00:00 2001 From: Fuwn Date: Tue, 3 Mar 2026 09:04:44 -0800 Subject: refactor(effect): add request body schema decoders to api routes --- src/lib/Effect/requestBody.test.ts | 56 +++++++++++++++++++++++ src/lib/Effect/requestBody.ts | 15 ++++++ src/routes/api/badges/+server.ts | 14 +++++- src/routes/api/configuration/+server.ts | 7 ++- src/routes/api/notifications/subscribe/+server.ts | 11 ++++- src/routes/api/preferences/+server.ts | 9 +++- 6 files changed, 107 insertions(+), 5 deletions(-) create mode 100644 src/lib/Effect/requestBody.test.ts create mode 100644 src/lib/Effect/requestBody.ts (limited to 'src') diff --git a/src/lib/Effect/requestBody.test.ts b/src/lib/Effect/requestBody.test.ts new file mode 100644 index 00000000..8ca31036 --- /dev/null +++ b/src/lib/Effect/requestBody.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { Schema } from "effect"; +import { + decodeRequestJsonOrThrow, + decodeUnknownOrThrow, +} from "$lib/Effect/requestBody"; + +describe("request body effect decoders", () => { + it("decodes unknown payload with a schema", () => { + const decoded = decodeUnknownOrThrow(Schema.Array(Schema.String), [ + "a", + "b", + "c", + ]); + + expect(decoded).toEqual(["a", "b", "c"]); + }); + + it("throws when payload does not match schema", () => { + expect(() => + decodeUnknownOrThrow(Schema.Array(Schema.String), ["a", 2]), + ).toThrowError(); + }); + + it("decodes request.json body with schema", async () => { + const request = new Request("https://due.moe/api/preferences", { + method: "PUT", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(["seasonal", "ongoing"]), + }); + const decoded = await decodeRequestJsonOrThrow( + request, + Schema.Array(Schema.String), + ); + + expect(decoded).toEqual(["seasonal", "ongoing"]); + }); + + it("decodes request.json object body with record schema", async () => { + const request = new Request("https://due.moe/api/configuration", { + method: "PUT", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ theme: "dark", notifications: true }), + }); + const decoded = await decodeRequestJsonOrThrow( + request, + Schema.Record(Schema.String, Schema.Unknown), + ); + + expect(decoded).toEqual({ theme: "dark", notifications: true }); + }); +}); diff --git a/src/lib/Effect/requestBody.ts b/src/lib/Effect/requestBody.ts new file mode 100644 index 00000000..c43c30b2 --- /dev/null +++ b/src/lib/Effect/requestBody.ts @@ -0,0 +1,15 @@ +import { Schema } from "effect"; + +type SyncDecodingSchema = Schema.Top & { + readonly DecodingServices: never; +}; + +export const decodeUnknownOrThrow = ( + schema: S, + input: unknown, +): S["Type"] => Schema.decodeUnknownSync(schema)(input); + +export const decodeRequestJsonOrThrow = async ( + request: Request, + schema: S, +): Promise => decodeUnknownOrThrow(schema, await request.json()); diff --git a/src/routes/api/badges/+server.ts b/src/routes/api/badges/+server.ts index abd5c0cd..912782dd 100644 --- a/src/routes/api/badges/+server.ts +++ b/src/routes/api/badges/+server.ts @@ -1,5 +1,6 @@ import { userIdentity } from "$lib/Data/AniList/identity"; import { decodeAuthCookieOrThrow } from "$lib/Effect/authCookie"; +import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody"; import { removeAllUserBadges, removeUserBadge, @@ -7,14 +8,17 @@ import { getUserBadges, addUserBadge, type Badge, + type BadgeInput, migrateCategory, setShadowHidden, setShadowHiddenBadge, incrementClickCount, } from "$lib/Database/SB/User/badges"; +import { Schema } from "effect"; import privilegedUser from "$lib/Utility/privilegedUser"; const unauthorised = new Response("Unauthorised", { status: 401 }); +const importedBadgeSchema = Schema.Record(Schema.String, Schema.Unknown); const badges = async (id: number) => Response.json(await getUserBadges(id), { @@ -65,9 +69,15 @@ export const PUT = async ({ cookies, url, request }) => { setShadowHidden(Number(url.searchParams.get("shadowHide")), authorised); if (url.searchParams.get("import") || undefined) { + const importedBadges = await decodeRequestJsonOrThrow( + request, + Schema.Array(importedBadgeSchema), + ); + await Promise.all( - (await request.json()).map( - async (badge: Badge) => await addUserBadge(identity.id, badge), + importedBadges.map( + async (badge) => + await addUserBadge(identity.id, badge as unknown as BadgeInput), ), ); diff --git a/src/routes/api/configuration/+server.ts b/src/routes/api/configuration/+server.ts index 033e8dea..14c49766 100644 --- a/src/routes/api/configuration/+server.ts +++ b/src/routes/api/configuration/+server.ts @@ -1,10 +1,12 @@ import { userIdentity } from "$lib/Data/AniList/identity"; import { decodeAuthCookieOrThrow } from "$lib/Effect/authCookie"; +import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody"; import { deleteUserConfiguration, getUserConfiguration, setUserConfiguration, } from "$lib/Database/SB/User/configuration"; +import { Schema } from "effect"; const unauthorised = new Response("Unauthorised", { status: 401 }); @@ -27,7 +29,10 @@ export const PUT = async ({ cookies, request }) => { return Response.json( await setUserConfiguration((await userIdentity(user)).id, { - configuration: await request.json(), + configuration: await decodeRequestJsonOrThrow( + request, + Schema.Record(Schema.String, Schema.Unknown), + ), }), { headers: { diff --git a/src/routes/api/notifications/subscribe/+server.ts b/src/routes/api/notifications/subscribe/+server.ts index 499e2cf0..806785e4 100644 --- a/src/routes/api/notifications/subscribe/+server.ts +++ b/src/routes/api/notifications/subscribe/+server.ts @@ -1,6 +1,8 @@ import { userIdentity } from "$lib/Data/AniList/identity"; import { setUserSubscription } from "$lib/Database/SB/User/notifications"; import { decodeAuthCookieOrThrow } from "$lib/Effect/authCookie"; +import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody"; +import { Schema } from "effect"; const unauthorised = new Response("Unauthorised", { status: 401 }); @@ -15,7 +17,14 @@ export const POST = async ({ cookies, request, url }) => { if (!userId) return unauthorised; - await setUserSubscription(userId, await request.json(), fingerprint); + await setUserSubscription( + userId, + (await decodeRequestJsonOrThrow( + request, + Schema.Record(Schema.String, Schema.Unknown), + )) as unknown as JSON, + fingerprint, + ); return new Response(null, { status: 200 }); }; diff --git a/src/routes/api/preferences/+server.ts b/src/routes/api/preferences/+server.ts index 0a30274b..0f62fb76 100644 --- a/src/routes/api/preferences/+server.ts +++ b/src/routes/api/preferences/+server.ts @@ -1,5 +1,6 @@ import { userIdentity } from "$lib/Data/AniList/identity"; import { decodeAuthCookieOrThrow } from "$lib/Effect/authCookie"; +import { decodeRequestJsonOrThrow } from "$lib/Effect/requestBody"; import { getUserPreferences, toggleHideMissingBadges, @@ -9,6 +10,7 @@ import { togglePinnedBadgeWallCategory, setPinnedBadgeWallCategories, } from "$lib/Database/SB/User/preferences"; +import { Schema } from "effect"; const unauthorised = new Response("Unauthorised", { status: 401 }); @@ -68,7 +70,12 @@ export const PUT = async ({ url, cookies, request }) => { if (url.searchParams.get("setCategories") !== null) return Response.json( - await setPinnedBadgeWallCategories(userId, await request.json()), + await setPinnedBadgeWallCategories(userId, [ + ...(await decodeRequestJsonOrThrow( + request, + Schema.Array(Schema.String), + )), + ]), { headers: { "Access-Control-Allow-Origin": "https://due.moe", -- cgit v1.2.3