diff --git a/api/src/base.ts b/api/src/base.ts index 88920b2b..fa157359 100644 --- a/api/src/base.ts +++ b/api/src/base.ts @@ -11,6 +11,7 @@ export const base = new Elysia({ name: "base" }) } as KError; } if (code === "INTERNAL_SERVER_ERROR") { + console.error(error); return { status: 500, message: error.message, diff --git a/api/src/controllers/movies.ts b/api/src/controllers/movies.ts index 35647ab7..fbe9440d 100644 --- a/api/src/controllers/movies.ts +++ b/api/src/controllers/movies.ts @@ -1,57 +1,45 @@ +import { and, eq, sql } from "drizzle-orm"; import { Elysia, t } from "elysia"; -import { Movie, MovieTranslation } from "../models/movie"; +import { KError } from "~/models/error"; +import { isUuid, processLanguages } from "~/models/utils"; +import { comment } from "~/utils"; import { db } from "../db"; import { shows, showTranslations } from "../db/schema/shows"; -import { eq, and, sql, or } from "drizzle-orm"; import { getColumns } from "../db/schema/utils"; import { bubble } from "../models/examples"; -import { comment } from "~/utils"; -import { processLanguages } from "~/models/utils"; +import { Movie, MovieTranslation } from "../models/movie"; -const translations = db - .selectDistinctOn([showTranslations.pk]) - .from(showTranslations) - // .where( - // or( - // eq(showTranslations.language, sql`any(${sql.placeholder("langs")})`), - // eq(showTranslations.language, shows.originalLanguage), - // ), - // ) - .orderBy( - showTranslations.pk, - sql`array_position(${sql.placeholder("langs")}, ${showTranslations.language})`, - ) - .as("t"); +// drizzle is bugged and doesn't allow js arrays to be used in raw sql. +export function sqlarr(array: unknown[]) { + return `{${array.map((item) => `"${item}"`).join(",")}}`; +} + +const getTranslationQuery = (languages: string[]) => { + const fallback = languages.includes("*"); + const query = db + .selectDistinctOn([showTranslations.pk]) + .from(showTranslations) + .where( + fallback + ? undefined + : eq(showTranslations.language, sql`any(${sqlarr(languages)})`), + ) + .orderBy( + showTranslations.pk, + sql`array_position(${sqlarr(languages)}, ${showTranslations.language})`, + ) + .as("t"); + + const { pk, ...col } = getColumns(query); + return [query, col] as const; +}; const { pk: _, kind, startAir, endAir, ...moviesCol } = getColumns(shows); -const { pk, language, ...translationsCol } = getColumns(translations); - -const findMovie = db - .select({ - ...moviesCol, - ...translationsCol, - airDate: startAir, - }) - .from(shows) - .innerJoin(translations, eq(shows.pk, translations.pk)) - .where( - and( - eq(shows.kind, "movie"), - // or( - // eq(shows.id, sql.placeholder("id")), - eq(shows.slug, sql.placeholder("id")), - // ), - ), - ) - // .orderBy() - .limit(1) - .prepare("findMovie"); export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) .model({ movie: Movie, "movie-translation": MovieTranslation, - error: t.Object({}), }) .guard({ params: t.Object({ @@ -61,7 +49,7 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) }), }), headers: t.Object({ - "Accept-Language": t.String({ + "accept-language": t.String({ default: "*", examples: "en-us, ja;q=0.5", description: comment` @@ -71,26 +59,66 @@ export const movies = new Elysia({ prefix: "/movies", tags: ["movies"] }) `, }), }), - response: { 200: "movie", 404: "error" }, + response: { + 200: "movie", + 404: { + ...KError, + description: "No movie found with the given id or slug.", + }, + 422: { + ...KError, + description: comment` + The Accept-Language header can't be satisfied (all languages listed are + unavailable). Try with another languages or add * to the list of languages + to fallback to any language. + `, + }, + }, }) .get( "/:id", async ({ params: { id }, - headers: { "Accept-Language": languages }, + headers: { "accept-language": languages }, error, + set, }) => { const langs = processLanguages(languages); - console.log(langs); - console.log(findMovie.getQuery()); - const ret = await findMovie.execute({ id, langs }); - console.log(ret); - if (ret.length !== 1) return error(404, {}); - return ret[0]; + const [transQ, transCol] = getTranslationQuery(langs); + + const idFilter = isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id); + + const [ret] = await db + .select({ + ...moviesCol, + ...transCol, + airDate: startAir, + }) + .from(shows) + .leftJoin(transQ, eq(shows.pk, transQ.pk)) + .where(and(eq(shows.kind, "movie"), idFilter)) + .limit(1); + + if (!ret) { + return error(404, { + status: 404, + message: "Movie not found", + details: undefined, + }); + } + if (!ret.language) { + return error(422, { + status: 422, + message: "Accept-Language header could not be satisfied.", + details: undefined, + }); + } + set.headers["content-language"] = ret.language; + return ret; }, { detail: { description: "Get a movie by id or slug", }, }, - ) + ); diff --git a/api/src/models/utils/resource.ts b/api/src/models/utils/resource.ts index 4c47cf48..6e837397 100644 --- a/api/src/models/utils/resource.ts +++ b/api/src/models/utils/resource.ts @@ -1,4 +1,5 @@ import { FormatRegistry } from "@sinclair/typebox"; +import { TypeCompiler } from "@sinclair/typebox/compiler"; import { t } from "elysia"; export const slugPattern = "^[a-z0-9-]+$"; @@ -11,3 +12,6 @@ export const Resource = t.Object({ id: t.String({ format: "uuid" }), slug: t.String({ format: "slug" }), }); + +const checker = TypeCompiler.Compile(t.String({ format: "uuid" })); +export const isUuid = (id: string) => checker.Check(id); diff --git a/api/tests/get-movies.test.ts b/api/tests/get-movies.test.ts index e6cefacb..95f5e86b 100644 --- a/api/tests/get-movies.test.ts +++ b/api/tests/get-movies.test.ts @@ -22,6 +22,8 @@ const getMovie = async (id: string, langs: string) => { return [resp, body] as const; }; +let bubbleId = ""; + function expectStatus(resp: Response, body: object) { const matcher = expect({ ...body, status: resp.status }); return { @@ -41,12 +43,50 @@ describe("Get movie", () => { name: bubble.translations.en.name, }); }); + it("Retrive by id", async () => { + const [resp, body] = await getMovie(bubbleId, "en"); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + id: bubbleId, + slug: bubble.slug, + name: bubble.translations.en.name, + }); + }); + it("Get non available translation", async () => { + const [resp, body] = await getMovie(bubble.slug, "fr"); + + expectStatus(resp, body).toBe(422); + expect(body).toMatchObject({ + status: 422, + }); + }); + it("Get first available language", async () => { + const [resp, body] = await getMovie(bubble.slug, "fr,en"); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + slug: bubble.slug, + name: bubble.translations.en.name, + }); + expect(resp.headers.get("Content-Language")).toBe("en"); + }); + it("Use language fallback", async () => { + const [resp, body] = await getMovie(bubble.slug, "fr,ja,*"); + + expectStatus(resp, body).toBe(200); + expect(body).toMatchObject({ + slug: bubble.slug, + name: bubble.translations.en.name, + }); + expect(resp.headers.get("Content-Language")).toBe("en"); + }); }); beforeAll(async () => { const ret = await seedMovie(bubble); - console.log("seed bubble", ret); + bubbleId = ret.id; }); afterAll(async () => { - // await db.delete(shows).where(eq(shows.slug, bubble.slug)); + await db.delete(shows).where(eq(shows.slug, bubble.slug)); });