mirror of
https://github.com/zoriya/Kyoo.git
synced 2025-05-31 04:04:21 -04:00
Handle filter parsing with typebox
This commit is contained in:
parent
efbec85b67
commit
3d6912b60d
@ -2,9 +2,13 @@ import Elysia from "elysia";
|
|||||||
import type { KError } from "./models/error";
|
import type { KError } from "./models/error";
|
||||||
|
|
||||||
export const base = new Elysia({ name: "base" })
|
export const base = new Elysia({ name: "base" })
|
||||||
.onError(({code, error}) => {
|
.onError(({ code, error }) => {
|
||||||
if (code === "VALIDATION") {
|
if (code === "VALIDATION") {
|
||||||
const details = JSON.parse(error.message);
|
const details = JSON.parse(error.message);
|
||||||
|
if (details.code === "KError") {
|
||||||
|
delete details.code;
|
||||||
|
return details;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
status: error.status,
|
status: error.status,
|
||||||
message: `Validation error on ${details.on}.`,
|
message: `Validation error on ${details.on}.`,
|
||||||
|
@ -1,15 +1,19 @@
|
|||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
import { and, desc, eq, sql } from "drizzle-orm";
|
||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import { KError } from "~/models/error";
|
import { KError } from "~/models/error";
|
||||||
import { Genre, isUuid, processLanguages } from "~/models/utils";
|
import {
|
||||||
import { comment, RemovePrefix } from "~/utils";
|
type FilterDef,
|
||||||
|
Genre,
|
||||||
|
isUuid,
|
||||||
|
processLanguages,
|
||||||
|
} from "~/models/utils";
|
||||||
|
import { comment, type RemovePrefix } from "~/utils";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { shows, showTranslations } from "../db/schema/shows";
|
import { shows, showTranslations } from "../db/schema/shows";
|
||||||
import { getColumns } from "../db/schema/utils";
|
import { getColumns } from "../db/schema/utils";
|
||||||
import { bubble } from "../models/examples";
|
import { bubble } from "../models/examples";
|
||||||
import { Movie, MovieStatus, MovieTranslation } from "../models/movie";
|
import { Movie, MovieStatus, MovieTranslation } from "../models/movie";
|
||||||
import { Page } from "~/models/utils/page";
|
import { Filter, type Page } from "~/models/utils";
|
||||||
import { type Filter, parseFilters } from "~/models/utils/filters-sql";
|
|
||||||
|
|
||||||
// drizzle is bugged and doesn't allow js arrays to be used in raw sql.
|
// drizzle is bugged and doesn't allow js arrays to be used in raw sql.
|
||||||
export function sqlarr(array: unknown[]) {
|
export function sqlarr(array: unknown[]) {
|
||||||
@ -38,7 +42,7 @@ const getTranslationQuery = (languages: string[]) => {
|
|||||||
|
|
||||||
const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows);
|
const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows);
|
||||||
|
|
||||||
const movieFilters: Filter = {
|
const movieFilters: FilterDef = {
|
||||||
genres: {
|
genres: {
|
||||||
column: shows.genres,
|
column: shows.genres,
|
||||||
type: "enum",
|
type: "enum",
|
||||||
@ -160,7 +164,6 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
|||||||
if (key === "airDate") return { key: "startAir" as const, desc };
|
if (key === "airDate") return { key: "startAir" as const, desc };
|
||||||
return { key, desc };
|
return { key, desc };
|
||||||
});
|
});
|
||||||
const filters = parseFilters(filter, movieFilters);
|
|
||||||
|
|
||||||
// TODO: Add sql indexes on order keys
|
// TODO: Add sql indexes on order keys
|
||||||
|
|
||||||
@ -173,7 +176,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] })
|
|||||||
})
|
})
|
||||||
.from(shows)
|
.from(shows)
|
||||||
.innerJoin(transQ, eq(shows.pk, transQ.pk))
|
.innerJoin(transQ, eq(shows.pk, transQ.pk))
|
||||||
.where(filters)
|
.where(filter)
|
||||||
.orderBy(
|
.orderBy(
|
||||||
...order.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])),
|
...order.map((x) => (x.desc ? desc(shows[x.key]) : shows[x.key])),
|
||||||
shows.pk,
|
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
|
// TODO: support explode: true (allow sort=slug,-createdAt). needs a pr to elysia
|
||||||
{ explode: false, default: ["slug"] },
|
{ explode: false, default: ["slug"] },
|
||||||
),
|
),
|
||||||
filter: t.Optional(
|
filter: t.Optional(Filter({ def: movieFilters })),
|
||||||
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",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
limit: t.Integer({
|
limit: t.Integer({
|
||||||
minimum: 1,
|
minimum: 1,
|
||||||
maximum: 250,
|
maximum: 250,
|
||||||
|
@ -6,3 +6,9 @@ export const KError = t.Object({
|
|||||||
details: t.Optional(t.Any()),
|
details: t.Optional(t.Any()),
|
||||||
});
|
});
|
||||||
export type KError = typeof KError.static;
|
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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
49
api/src/models/utils/filters/index.ts
Normal file
49
api/src/models/utils/filters/index.ts
Normal file
@ -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);
|
||||||
|
};
|
@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
type Column,
|
|
||||||
eq,
|
eq,
|
||||||
gt,
|
gt,
|
||||||
gte,
|
gte,
|
||||||
@ -13,29 +12,9 @@ import {
|
|||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { comment } from "~/utils";
|
import { comment } from "~/utils";
|
||||||
import type { KError } from "../error";
|
import type { FilterDef } from "./index";
|
||||||
import { type Expression, expression, type Operator } from "./filters";
|
import type { Expression, Operator } from "./parser";
|
||||||
|
import { KErrorT } from "~/models/error";
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
const opMap: Record<Operator, typeof eq> = {
|
const opMap: Record<Operator, typeof eq> = {
|
||||||
eq: eq,
|
eq: eq,
|
||||||
@ -47,58 +26,54 @@ const opMap: Record<Operator, typeof eq> = {
|
|||||||
has: eq,
|
has: eq,
|
||||||
};
|
};
|
||||||
|
|
||||||
const toDrizzle = (expr: Expression, config: Filter): SQL | KError => {
|
export const toDrizzle = (expr: Expression, config: FilterDef): SQL => {
|
||||||
switch (expr.type) {
|
switch (expr.type) {
|
||||||
case "op": {
|
case "op": {
|
||||||
const where = `${expr.property} ${expr.operator} ${expr.value}`;
|
const where = `${expr.property} ${expr.operator} ${expr.value.value}`;
|
||||||
const prop = config[expr.property];
|
const prop = config[expr.property];
|
||||||
|
|
||||||
if (!prop) {
|
if (!prop) {
|
||||||
return {
|
throw new KErrorT(
|
||||||
status: 422,
|
comment`
|
||||||
message: comment`
|
|
||||||
Invalid property: ${expr.property}.
|
Invalid property: ${expr.property}.
|
||||||
Expected one of ${Object.keys(config).join(", ")}.
|
Expected one of ${Object.keys(config).join(", ")}.
|
||||||
`,
|
`,
|
||||||
details: { in: where },
|
{ in: where },
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prop.type !== expr.value.type) {
|
if (prop.type !== expr.value.type) {
|
||||||
return {
|
throw new KErrorT(
|
||||||
status: 422,
|
comment`
|
||||||
message: comment`
|
|
||||||
Invalid value for property ${expr.property}.
|
Invalid value for property ${expr.property}.
|
||||||
Got ${expr.value.type} but expected ${prop.type}.
|
Got ${expr.value.type} but expected ${prop.type}.
|
||||||
`,
|
`,
|
||||||
details: { in: where },
|
{ in: where },
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
prop.type === "enum" &&
|
prop.type === "enum" &&
|
||||||
(expr.value.type === "enum" || expr.value.type === "string") &&
|
(expr.value.type === "enum" || expr.value.type === "string") &&
|
||||||
!prop.values.includes(expr.value.value)
|
!prop.values.includes(expr.value.value)
|
||||||
) {
|
) {
|
||||||
return {
|
throw new KErrorT(
|
||||||
status: 422,
|
comment`
|
||||||
message: comment`
|
|
||||||
Invalid value ${expr.value.value} for property ${expr.property}.
|
Invalid value ${expr.value.value} for property ${expr.property}.
|
||||||
Expected one of ${prop.values.join(", ")} but got ${expr.value.value}.
|
Expected one of ${prop.values.join(", ")} but got ${expr.value.value}.
|
||||||
`,
|
`,
|
||||||
details: { in: where },
|
{ in: where },
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prop.isArray) {
|
if (prop.isArray) {
|
||||||
if (expr.operator !== "has" && expr.operator !== "eq") {
|
if (expr.operator !== "has" && expr.operator !== "eq") {
|
||||||
return {
|
throw new KErrorT(
|
||||||
status: 422,
|
comment`
|
||||||
message: comment`
|
|
||||||
Property ${expr.property} is an array but you wanted to use the
|
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")
|
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})`;
|
return sql`${expr.value.value} = any(${prop.column})`;
|
||||||
}
|
}
|
||||||
@ -107,20 +82,15 @@ const toDrizzle = (expr: Expression, config: Filter): SQL | KError => {
|
|||||||
case "and": {
|
case "and": {
|
||||||
const lhs = toDrizzle(expr.lhs, config);
|
const lhs = toDrizzle(expr.lhs, config);
|
||||||
const rhs = toDrizzle(expr.rhs, config);
|
const rhs = toDrizzle(expr.rhs, config);
|
||||||
if ("status" in lhs) return lhs;
|
|
||||||
if ("status" in rhs) return rhs;
|
|
||||||
return and(lhs, rhs)!;
|
return and(lhs, rhs)!;
|
||||||
}
|
}
|
||||||
case "or": {
|
case "or": {
|
||||||
const lhs = toDrizzle(expr.lhs, config);
|
const lhs = toDrizzle(expr.lhs, config);
|
||||||
const rhs = toDrizzle(expr.rhs, config);
|
const rhs = toDrizzle(expr.rhs, config);
|
||||||
if ("status" in lhs) return lhs;
|
|
||||||
if ("status" in rhs) return rhs;
|
|
||||||
return or(lhs, rhs)!;
|
return or(lhs, rhs)!;
|
||||||
}
|
}
|
||||||
case "not": {
|
case "not": {
|
||||||
const lhs = toDrizzle(expr.expression, config);
|
const lhs = toDrizzle(expr.expression, config);
|
||||||
if ("status" in lhs) return lhs;
|
|
||||||
return not(lhs);
|
return not(lhs);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
@ -3,3 +3,5 @@ export * from "./genres";
|
|||||||
export * from "./image";
|
export * from "./image";
|
||||||
export * from "./language";
|
export * from "./language";
|
||||||
export * from "./resource";
|
export * from "./resource";
|
||||||
|
export * from "./filters";
|
||||||
|
export * from "./page";
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
// remove indent in multi-line comments
|
// remove indent in multi-line comments
|
||||||
export const comment = (str: TemplateStringsArray, ...values: any[]) =>
|
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<
|
export type RemovePrefix<
|
||||||
T extends string,
|
T extends string,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
import { describe, expect, it } from "bun:test";
|
||||||
import type { ParjsFailure } from "parjs/internal";
|
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(
|
function parse(
|
||||||
filter: string,
|
filter: string,
|
||||||
|
@ -23,6 +23,30 @@ const getMovie = async (id: string, langs?: string) => {
|
|||||||
const body = await resp.json();
|
const body = await resp.json();
|
||||||
return [resp, body] as const;
|
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 = "";
|
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 () => {
|
beforeAll(async () => {
|
||||||
const ret = await seedMovie(bubble);
|
const ret = await seedMovie(bubble);
|
||||||
bubbleId = ret.id;
|
bubbleId = ret.id;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user