diff --git a/api/src/base.ts b/api/src/base.ts index 5dc64f29..0acbd54b 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -2,9 +2,13 @@ import Elysia from "elysia"; import type { KError } from "./models/error"; export const base = new Elysia({ name: "base" }) - .onError(({code, error}) => { + .onError(({ code, error }) => { if (code === "VALIDATION") { const details = JSON.parse(error.message); + if (details.code === "KError") { + delete details.code; + return details; + } return { status: error.status, message: `Validation error on ${details.on}.`, diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index e6307278..a73e1c3c 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,15 +1,19 @@ import { and, desc, eq, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; import { KError } from "~/models/error"; -import { Genre, isUuid, processLanguages } from "~/models/utils"; -import { comment, RemovePrefix } from "~/utils"; +import { + type FilterDef, + Genre, + isUuid, + processLanguages, +} from "~/models/utils"; +import { comment, type RemovePrefix } from "~/utils"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; import { getColumns } from "../db/schema/utils"; import { bubble } from "../models/examples"; import { Movie, MovieStatus, MovieTranslation } from "../models/movie"; -import { Page } from "~/models/utils/page"; -import { type Filter, parseFilters } from "~/models/utils/filters-sql"; +import { Filter, type Page } from "~/models/utils"; // drizzle is bugged and doesn't allow js arrays to be used in raw sql. export function sqlarr(array: unknown[]) { @@ -38,7 +42,7 @@ const getTranslationQuery = (languages: string[]) => { const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows); -const movieFilters: Filter = { +const movieFilters: FilterDef = { genres: { column: shows.genres, type: "enum", @@ -160,7 +164,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) if (key === "airDate") return { key: "startAir" as const, desc }; return { key, desc }; }); - const filters = parseFilters(filter, movieFilters); // TODO: Add sql indexes on order keys @@ -173,7 +176,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }) .from(shows) .innerJoin(transQ, eq(shows.pk, transQ.pk)) - .where(filters) + .where(filter) .orderBy( ...order.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])), shows.pk, @@ -202,18 +205,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) // TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia { explode: false, default: ["slug"] }, ), - filter: t.Optional( - t.String({ - description: comment` - Filters to apply to the query. - This is based on [odata's filter specification](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter). - - Filters available: ${Object.keys(movieFilters).join(", ")} - `, - example: - "(rating gt 75 and genres has action) or status eq planned", - }), - ), + filter: t.Optional(Filter({ def: movieFilters })), limit: t.Integer({ minimum: 1, maximum: 250, diff --git a/api/src/models/error.ts b/api/src/models/error.ts index a2bf9822..4be8a968 100644 --- a/api/src/models/error.ts +++ b/api/src/models/error.ts @@ -6,3 +6,9 @@ export const KError = t.Object({ details: t.Optional(t.Any()), }); export type KError = typeof KError.static; + +export class KErrorT extends Error { + constructor(message: string, details?: any) { + super(JSON.stringify({ code: "KError", status: 422, message, details })); + } +} diff --git a/api/src/models/utils/filters/index.ts b/api/src/models/utils/filters/index.ts new file mode 100644 index 00000000..0255f7da --- /dev/null +++ b/api/src/models/utils/filters/index.ts @@ -0,0 +1,49 @@ +import type { Column } from "drizzle-orm"; +import { t } from "elysia"; +import { comment } from "~/utils"; +import { expression } from "./parser"; +import { toDrizzle } from "./to-sql"; +import { KErrorT } from "~/models/error"; + +export type FilterDef = { + [key: string]: + | { + column: Column; + type: "int" | "float" | "date" | "string"; + isArray?: boolean; + } + | { column: Column; type: "enum"; values: string[]; isArray?: boolean }; +}; + +export const Filter = ({ + def, + description = "Filters to apply to the query.", +}: { def: FilterDef; description?: string }) => + t + .Transform( + t.String({ + description: comment` + ${description} + This is based on [odata's filter specification](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#sec_SystemQueryOptionfilter). + + Filters available: ${Object.keys(def).join(", ")} + `, + example: "(rating gt 75 and genres has action) or status eq planned", + }), + ) + .Decode((filter) => { + return parseFilters(filter, def); + }) + .Encode(() => { + throw new Error("Can't encode filters"); + }); + +export const parseFilters = (filter: string | undefined, config: FilterDef) => { + if (!filter) return undefined; + const ret = expression.parse(filter); + if (!ret.isOk) { + throw new KErrorT(`Invalid filter: ${filter}.`, ret); + } + + return toDrizzle(ret.value, config); +}; diff --git a/api/src/models/utils/filters.ts b/api/src/models/utils/filters/parser.ts similarity index 100% rename from api/src/models/utils/filters.ts rename to api/src/models/utils/filters/parser.ts diff --git a/api/src/models/utils/filters-sql.ts b/api/src/models/utils/filters/to-sql.ts similarity index 58% rename from api/src/models/utils/filters-sql.ts rename to api/src/models/utils/filters/to-sql.ts index ad79ce9e..ad551dd8 100644 --- a/api/src/models/utils/filters-sql.ts +++ b/api/src/models/utils/filters/to-sql.ts @@ -1,6 +1,5 @@ import { and, - type Column, eq, gt, gte, @@ -13,29 +12,9 @@ import { sql, } from "drizzle-orm"; import { comment } from "~/utils"; -import type { KError } from "../error"; -import { type Expression, expression, type Operator } from "./filters"; - -export type Filter = { - [key: string]: - | { - column: Column; - type: "int" | "float" | "date" | "string"; - isArray?: boolean; - } - | { column: Column; type: "enum"; values: string[]; isArray?: boolean }; -}; - -export const parseFilters = (filter: string | undefined, config: Filter) => { - if (!filter) return undefined; - const ret = expression.parse(filter); - if (!ret.isOk) { - throw new Error("todo"); - // return { status: 422, message: `Invalid filter: ${filter}.`, details: ret } - } - - return toDrizzle(ret.value, config); -}; +import type { FilterDef } from "./index"; +import type { Expression, Operator } from "./parser"; +import { KErrorT } from "~/models/error"; const opMap: Record = { eq: eq, @@ -47,58 +26,54 @@ const opMap: Record = { has: eq, }; -const toDrizzle = (expr: Expression, config: Filter): SQL | KError => { +export const toDrizzle = (expr: Expression, config: FilterDef): SQL => { switch (expr.type) { case "op": { - const where = `${expr.property} ${expr.operator} ${expr.value}`; + const where = `${expr.property} ${expr.operator} ${expr.value.value}`; const prop = config[expr.property]; if (!prop) { - return { - status: 422, - message: comment` + throw new KErrorT( + comment` Invalid property: ${expr.property}. Expected one of ${Object.keys(config).join(", ")}. `, - details: { in: where }, - }; + { in: where }, + ); } if (prop.type !== expr.value.type) { - return { - status: 422, - message: comment` + throw new KErrorT( + comment` Invalid value for property ${expr.property}. Got ${expr.value.type} but expected ${prop.type}. `, - details: { in: where }, - }; + { in: where }, + ); } if ( prop.type === "enum" && (expr.value.type === "enum" || expr.value.type === "string") && !prop.values.includes(expr.value.value) ) { - return { - status: 422, - message: comment` + throw new KErrorT( + comment` Invalid value ${expr.value.value} for property ${expr.property}. Expected one of ${prop.values.join(", ")} but got ${expr.value.value}. `, - details: { in: where }, - }; + { in: where }, + ); } if (prop.isArray) { if (expr.operator !== "has" && expr.operator !== "eq") { - return { - status: 422, - message: comment` + throw new KErrorT( + comment` Property ${expr.property} is an array but you wanted to use the operator ${expr.operator}. Only "has" is supported ("eq" is also aliased to "has") `, - details: { in: where }, - }; + { in: where }, + ); } return sql`${expr.value.value} = any(${prop.column})`; } @@ -107,20 +82,15 @@ const toDrizzle = (expr: Expression, config: Filter): SQL | KError => { case "and": { const lhs = toDrizzle(expr.lhs, config); const rhs = toDrizzle(expr.rhs, config); - if ("status" in lhs) return lhs; - if ("status" in rhs) return rhs; return and(lhs, rhs)!; } case "or": { const lhs = toDrizzle(expr.lhs, config); const rhs = toDrizzle(expr.rhs, config); - if ("status" in lhs) return lhs; - if ("status" in rhs) return rhs; return or(lhs, rhs)!; } case "not": { const lhs = toDrizzle(expr.expression, config); - if ("status" in lhs) return lhs; return not(lhs); } default: diff --git a/api/src/models/utils/index.ts b/api/src/models/utils/index.ts index 98134fc5..31076b9a 100644 --- a/api/src/models/utils/index.ts +++ b/api/src/models/utils/index.ts @@ -3,3 +3,5 @@ export * from "./genres"; export * from "./image"; export * from "./language"; export * from "./resource"; +export * from "./filters"; +export * from "./page"; diff --git a/api/src/utils.ts b/api/src/utils.ts index 9e3e16d8..78f8181f 100644 --- a/api/src/utils.ts +++ b/api/src/utils.ts @@ -1,6 +1,8 @@ // remove indent in multi-line comments export const comment = (str: TemplateStringsArray, ...values: any[]) => - str.reduce((acc, str, i) => `${acc}${str}${values[i]}`).replace(/^[^\S\n]+/gm, ""); + str + .reduce((acc, str, i) => `${acc}${values[i - 1]}${str}`) + .replace(/(^[^\S\n]+|\s+$|^\s+)/gm, ""); export type RemovePrefix< T extends string, diff --git a/api/tests/misc/filter.test.ts b/api/tests/misc/filter.test.ts index d392158b..d1630e88 100644 --- a/api/tests/misc/filter.test.ts +++ b/api/tests/misc/filter.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { ParjsFailure } from "parjs/internal"; -import { type Expression, expression } from "~/models/utils/filters"; +import { type Expression, expression } from "~/models/utils/filters/parser"; function parse( filter: string, diff --git a/api/tests/movies/get-movies.test.ts b/api/tests/movies/get-movies.test.ts index fca0d08a..53a38f95 100644 --- a/api/tests/movies/get-movies.test.ts +++ b/api/tests/movies/get-movies.test.ts @@ -23,6 +23,30 @@ const getMovie = async (id: string, langs?: string) => { const body = await resp.json(); return [resp, body] as const; }; +const getMovies = async ({ + langs, + ...query +}: { filter?: string; langs?: string }) => { + // const params = Object.entries(query).reduce( + // (acc, [param, value]) => `${param}=${value}&`, + // "?", + // ); + const resp = await app.handle( + new Request( + `http://localhost/movies?${new URLSearchParams(query).toString()}`, + { + method: "GET", + headers: langs + ? { + "Accept-Language": langs, + } + : {}, + }, + ), + ); + const body = await resp.json(); + return [resp, body] as const; +}; let bubbleId = ""; @@ -95,6 +119,25 @@ describe("Get movie", () => { }); }); +describe("Get all movies", () => { + it("Invalid filter params", async () => { + const [resp, body] = await getMovies({ + filter: `slug eq ${bubble.slug}`, + langs: "en", + }); + + expectStatus(resp, body).toBe(422); + expect(body).toMatchObject({ + status: 422, + message: + "Invalid property: slug.\nExpected one of genres, rating, status, runtime, airDate, originalLanguage.", + details: { + in: "slug eq bubble", + }, + }); + }); +}); + beforeAll(async () => { const ret = await seedMovie(bubble); bubbleId = ret.id;