diff --git a/README.md b/README.md index 7dce3b3..43386ab 100644 --- a/README.md +++ b/README.md @@ -13,3 +13,7 @@ pre-commit hook for Sapling (`.sl/config`): [hooks] precommit = echo $HG_PARENT1 && bun prettier $(sl show -T "{file_adds} {file_mods}\n\n" $HG_PARENT1 --stat | head -n 1) --write ``` + +## Development + +If a route is internal-only or doesn't need to appear on the OpenAPI spec (that's autogenerated by Hono), use the `Hono` class. Otherwise, use the `OpenAPIHono` class from `@hono/zod-openapi`. diff --git a/bun.lockb b/bun.lockb index 7e88c6e..46b2da3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 4729e9d..9bc6da3 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,13 @@ }, "dependencies": { "@hono/zod-openapi": "^0.12.0", + "gql.tada": "^1.7.4", + "graphql-request": "^7.0.1", "hono": "^4.3.6", "zod": "^3.23.8" }, "devDependencies": { + "@0no-co/graphqlsp": "^1.12.3", "@cloudflare/workers-types": "^4.20240403.0", "@trivago/prettier-plugin-sort-imports": "^4.3.0", "@types/bun": "^1.1.2", diff --git a/src/controllers/health-check/index.ts b/src/controllers/health-check/index.ts index c1be941..9870299 100644 --- a/src/controllers/health-check/index.ts +++ b/src/controllers/health-check/index.ts @@ -7,6 +7,9 @@ const app = new OpenAPIHono(); const route = createRoute({ method: "get", path: "/", + summary: "Health check", + operationId: "healthCheck", + tags: ["aniplay"], responses: { 200: { content: { @@ -14,7 +17,7 @@ const route = createRoute({ schema: SuccessResponseSchema(), }, }, - description: "Retrieve the user", + description: "Server is up and running!", }, }, }); diff --git a/src/controllers/title/amvstrm.ts b/src/controllers/title/amvstrm.ts new file mode 100644 index 0000000..ee65ecc --- /dev/null +++ b/src/controllers/title/amvstrm.ts @@ -0,0 +1,76 @@ +import { Title } from "~/types/title"; + +export async function fetchTitleFromAmvstrm( + aniListId: number, +): Promise { + return Promise.all([ + fetch(`https://api-amvstrm.nyt92.eu.org/api/v2/info/${aniListId}`).then( + (res) => res.json() as Promise<any>, + ), + fetchMissingInformationFromAnify(aniListId).catch((err) => { + console.error("Failed to get missing information from Anify", err); + return null; + }), + ]).then( + async ([ + { + id, + idMal, + title: { english: englishTitle, userPreferred: userPreferredTitle }, + description, + episodes, + genres, + status, + bannerImage, + coverImage: { + extraLarge: extraLargeCoverImage, + large: largeCoverImage, + medium: mediumCoverImage, + }, + countryOfOrigin, + nextair: nextAiringEpisode, + score: { averageScore }, + }, + anifyInfo, + ]) => { + return { + id, + idMal, + title: { + userPreferred: userPreferredTitle, + english: englishTitle, + }, + description, + episodes, + genres, + status, + averageScore, + bannerImage: bannerImage ?? anifyInfo?.bannerImage, + coverImage: { + extraLarge: extraLargeCoverImage, + large: largeCoverImage, + medium: mediumCoverImage, + }, + countryOfOrigin: countryOfOrigin ?? anifyInfo?.countryOfOrigin, + nextAiringEpisode, + mediaListEntry: null, + }; + }, + ); +} + +type AnifyInformation = { + bannerImage: string | null; + countryOfOrigin: string; +}; + +function fetchMissingInformationFromAnify( + aniListId: number, +): Promise<AnifyInformation> { + return fetch(`https://api.anify.tv/info?id=${aniListId}`) + .then((res) => res.json() as Promise<AnifyInformation>) + .then(({ bannerImage, countryOfOrigin }) => ({ + bannerImage, + countryOfOrigin, + })); +} diff --git a/src/controllers/title/anilist.ts b/src/controllers/title/anilist.ts new file mode 100644 index 0000000..fb8fd21 --- /dev/null +++ b/src/controllers/title/anilist.ts @@ -0,0 +1,32 @@ +import { graphql } from "gql.tada"; +import { GraphQLClient } from "graphql-request"; + +import type { Title } from "~/types/title"; + +import { MediaFragment } from "./mediaFragment"; + +const GetTitleQuery = graphql( + ` + query GetTitle($id: Int!) { + Media(id: $id) { + ...Media + } + } + `, + [MediaFragment], +); + +export async function fetchTitleFromAnilist( + id: number, + token: string | undefined, +): Promise<Title | undefined> { + const client = new GraphQLClient("https://graphql.anilist.co/"); + const headers = new Headers(); + if (token) { + headers.append("Authorization", `Bearer ${token}`); + } + + return client + .request(GetTitleQuery, { id }, headers) + .then((data) => data?.Media ?? undefined); +} diff --git a/src/controllers/title/index.spec.ts b/src/controllers/title/index.spec.ts new file mode 100644 index 0000000..9a709a8 --- /dev/null +++ b/src/controllers/title/index.spec.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "bun:test"; + +import app from "~/index"; +import { server } from "~/mocks"; + +server.listen(); + +describe('requests the "/title" route', () => { + it("with a valid id & token", async () => { + const response = await app.request("/title?id=10", { + headers: new Headers({ "x-anilist-token": "asd" }), + }); + + expect(response.json()).resolves.toEqual({ + success: true, + result: { + nextAiringEpisode: null, + mediaListEntry: { + status: "CURRENT", + progress: 1, + id: 402665918, + }, + countryOfOrigin: "JP", + coverImage: { + medium: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg", + large: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg", + extraLarge: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg", + }, + averageScore: 66, + bannerImage: + "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg", + status: "FINISHED", + genres: ["Fantasy", "Thriller"], + episodes: 6, + description: + 'Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"\n<br><br>\nThe pages of Grimms\' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.\n<br><br>\n(Source: Netflix Anime)', + title: { + userPreferred: "The Grimm Variations", + english: "The Grimm Variations", + }, + idMal: 49210, + id: 135643, + }, + }); + expect(response.status).toBe(200); + }); + + it("with a valid id but no token", async () => { + const response = await app.request("/title?id=10"); + + expect(response.json()).resolves.toEqual({ + success: true, + result: { + nextAiringEpisode: null, + mediaListEntry: null, + countryOfOrigin: "JP", + coverImage: { + medium: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg", + large: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg", + extraLarge: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg", + }, + averageScore: 66, + bannerImage: + "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg", + status: "FINISHED", + genres: ["Fantasy", "Thriller"], + episodes: 6, + description: + 'Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"\n<br><br>\nThe pages of Grimms\' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.\n<br><br>\n(Source: Netflix Anime)', + title: { + userPreferred: "The Grimm Variations", + english: "The Grimm Variations", + }, + idMal: 49210, + id: 135643, + }, + }); + expect(response.status).toBe(200); + }); + + it("with an unknown title from anilist but valid title from amvstrm", async () => { + const response = await app.request("/title?id=50"); + + expect(response.json()).resolves.toEqual({ + success: true, + result: { + nextAiringEpisode: null, + mediaListEntry: null, + coverImage: { + medium: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx151807-yxY3olrjZH4k.png", + large: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx151807-yxY3olrjZH4k.png", + }, + averageScore: 83, + bannerImage: + "https://s4.anilist.co/file/anilistcdn/media/anime/banner/151807-37yfQA3ym8PA.jpg", + status: "FINISHED", + genres: ["Action", "Adventure", "Fantasy"], + episodes: 12, + description: + "They say whatever doesn’t kill you makes you stronger, but that’s not the case for the world’s weakest hunter Sung Jinwoo. After being brutally slaughtered by monsters in a high-ranking dungeon, Jinwoo came back with the System, a program only he could see, that’s leveling him up in every way. Now, he’s inspired to discover the secrets behind his powers and the dungeon that spawned them.<br>\n<br>\n(Source: Crunchyroll) <br><br>", + title: { + userPreferred: "Ore dake Level Up na Ken", + english: "Solo Leveling", + }, + idMal: 52299, + id: 151807, + countryOfOrigin: "JP", + }, + }); + expect(response.status).toBe(200); + }); + + it("with an unknown title from all sources", async () => { + const response = await app.request("/title?id=-1"); + + expect(response.json()).resolves.toEqual({ success: false }); + expect(response.status).toBe(404); + }); +}); diff --git a/src/controllers/title/index.ts b/src/controllers/title/index.ts new file mode 100644 index 0000000..d3a1d66 --- /dev/null +++ b/src/controllers/title/index.ts @@ -0,0 +1,61 @@ +import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi"; + +import { fetchFromMultipleSources } from "~/libs/fetchFromMultipleSources"; +import { + AniListIdSchema, + ErrorResponseSchema, + SuccessResponseSchema, +} from "~/types/schema"; +import { Title } from "~/types/title"; + +import { fetchTitleFromAmvstrm } from "./amvstrm"; +import { fetchTitleFromAnilist } from "./anilist"; + +const app = new OpenAPIHono(); + +const route = createRoute({ + tags: ["aniplay", "title"], + operationId: "fetchTitle", + summary: "Fetch title information", + method: "get", + path: "/", + request: { + query: z.object({ id: AniListIdSchema }), + headers: z.object({ "x-anilist-token": z.string().nullish() }), + }, + responses: { + 200: { + content: { + "application/json": { + schema: SuccessResponseSchema(Title), + }, + }, + description: "Returns title information", + }, + "404": { + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + description: "Title could not be found", + }, + }, +}); + +app.openapi(route, async (c) => { + const aniListId = Number(c.req.query("id")); + const aniListToken = c.req.header("X-AniList-Token"); + + const title = await fetchFromMultipleSources([ + () => fetchTitleFromAnilist(aniListId, aniListToken ?? undefined), + () => fetchTitleFromAmvstrm(aniListId), + ]); + if (!title) { + return c.json({ success: false }, 404); + } + + return c.json({ success: true, result: title }, 200); +}); + +export default app; diff --git a/src/controllers/title/mediaFragment.ts b/src/controllers/title/mediaFragment.ts new file mode 100644 index 0000000..edd62e0 --- /dev/null +++ b/src/controllers/title/mediaFragment.ts @@ -0,0 +1,35 @@ +import { graphql } from "gql.tada"; + +export const MediaFragment = graphql(` + fragment Media on Media { + id + idMal + title { + english + userPreferred + } + type + description + episodes + genres + status + bannerImage + averageScore + coverImage { + extraLarge + large + medium + } + countryOfOrigin + mediaListEntry { + id + progress + status + } + nextAiringEpisode { + timeUntilAiring + airingAt + episode + } + } +`); diff --git a/src/index.ts b/src/index.ts index 7048a4e..206aa92 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,10 @@ app.route( (controller) => controller.default, ), ); +app.route( + "/title", + await import("~/controllers/title").then((controller) => controller.default), +); // The OpenAPI documentation will be available at /doc app.doc("/doc", { diff --git a/src/mocks/amvstrm/title.ts b/src/mocks/amvstrm/title.ts new file mode 100644 index 0000000..97e2105 --- /dev/null +++ b/src/mocks/amvstrm/title.ts @@ -0,0 +1,602 @@ +import { HttpResponse, http } from "msw"; + +export function getAmvstrmTitle() { + return http.get( + "https://api-amvstrm.nyt92.eu.org/api/v2/info/:aniListId", + ({ params }) => { + const aniListId = Number(params["aniListId"] as string); + + if (aniListId == -1) { + return HttpResponse.json({ + code: 404, + message: + "The requested resource could not be found but may be available in the future. Subsequent requests by the client are permissible.", + }); + } + + if (aniListId == 50) { + return HttpResponse.json({ + code: 200, + message: "success", + id: 151807, + idMal: 52299, + id_provider: { + idGogo: "ore-dake-level-up-na-ken", + idGogoDub: "ore-dake-level-up-na-ken-korean-dub", + idZoro: "solo-leveling-18718", + id9anime: "solo-leveling.3rpv2", + idPahe: "5421", + }, + title: { + romaji: "Ore dake Level Up na Ken", + english: "Solo Leveling", + native: "俺だけレベルアップな件", + userPreferred: "Ore dake Level Up na Ken", + }, + dub: true, + description: + "They say whatever doesn’t kill you makes you stronger, but that’s not the case for the world’s weakest hunter Sung Jinwoo. After being brutally slaughtered by monsters in a high-ranking dungeon, Jinwoo came back with the System, a program only he could see, that’s leveling him up in every way. Now, he’s inspired to discover the secrets behind his powers and the dungeon that spawned them.<br>\n<br>\n(Source: Crunchyroll) <br><br>", + coverImage: { + large: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx151807-yxY3olrjZH4k.png", + medium: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx151807-yxY3olrjZH4k.png", + color: "#35bbf1", + }, + bannerImage: + "https://s4.anilist.co/file/anilistcdn/media/anime/banner/151807-37yfQA3ym8PA.jpg", + genres: ["Action", "Adventure", "Fantasy"], + tags: [ + { + id: 604, + name: "Dungeon", + }, + { + id: 82, + name: "Male Protagonist", + }, + { + id: 321, + name: "Urban Fantasy", + }, + { + id: 66, + name: "Super Power", + }, + { + id: 29, + name: "Magic", + }, + { + id: 1243, + name: "Necromancy", + }, + { + id: 326, + name: "Cultivation", + }, + { + id: 111, + name: "War", + }, + { + id: 104, + name: "Anti-Hero", + }, + { + id: 94, + name: "Gore", + }, + { + id: 636, + name: "Cosmic Horror", + }, + { + id: 85, + name: "Tragedy", + }, + { + id: 43, + name: "Swordplay", + }, + { + id: 56, + name: "Shounen", + }, + { + id: 146, + name: "Alternate Universe", + }, + { + id: 96, + name: "Time Manipulation", + }, + { + id: 1068, + name: "Angels", + }, + { + id: 93, + name: "Post-Apocalyptic", + }, + { + id: 217, + name: "Dystopian", + }, + { + id: 1310, + name: "Travel", + }, + { + id: 1045, + name: "Heterosexual", + }, + { + id: 488, + name: "Age Regression", + }, + { + id: 244, + name: "Isekai", + }, + { + id: 171, + name: "Bullying", + }, + { + id: 224, + name: "Dragons", + }, + { + id: 255, + name: "Ninja", + }, + ], + status: "FINISHED", + format: "TV", + episodes: 12, + year: 2024, + season: "WINTER", + duration: 24, + startIn: { + year: 2024, + month: 1, + day: 7, + }, + endIn: { + year: 2024, + month: 3, + day: 31, + }, + nextair: null, + score: { + averageScore: 83, + decimalScore: 8.3, + }, + popularity: 196143, + siteUrl: "https://anilist.co/anime/151807", + trailer: { + id: "HkIKAnwLZCw", + site: "youtube", + thumbnail: "https://i.ytimg.com/vi/HkIKAnwLZCw/hqdefault.jpg", + }, + studios: [ + { + name: "A-1 Pictures", + }, + { + name: "Aniplex", + }, + { + name: "Netmarble", + }, + { + name: "D&C MEDIA", + }, + { + name: "Kakao piccoma", + }, + { + name: "Crunchyroll", + }, + ], + relation: [ + { + id: 105398, + idMal: 121496, + title: { + romaji: "Na Honjaman Level Up", + english: "Solo Leveling", + native: "나 혼자만 레벨업", + userPreferred: "Na Honjaman Level Up", + }, + coverImage: { + large: + "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx105398-b673Vt5ZSuz3.jpg", + medium: + "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx105398-b673Vt5ZSuz3.jpg", + color: null, + }, + bannerImage: + "https://s4.anilist.co/file/anilistcdn/media/manga/banner/105398-4UrEhdqZukrg.jpg", + genres: ["Action", "Adventure", "Fantasy"], + tags: [ + { + id: 604, + name: "Dungeon", + }, + { + id: 82, + name: "Male Protagonist", + }, + { + id: 111, + name: "War", + }, + { + id: 66, + name: "Super Power", + }, + { + id: 207, + name: "Full Color", + }, + { + id: 29, + name: "Magic", + }, + { + id: 1243, + name: "Necromancy", + }, + { + id: 321, + name: "Urban Fantasy", + }, + { + id: 253, + name: "Gods", + }, + { + id: 109, + name: "Primarily Adult Cast", + }, + { + id: 103, + name: "Politics", + }, + { + id: 93, + name: "Post-Apocalyptic", + }, + { + id: 15, + name: "Demons", + }, + { + id: 85, + name: "Tragedy", + }, + { + id: 308, + name: "Video Games", + }, + { + id: 365, + name: "Memory Manipulation", + }, + { + id: 96, + name: "Time Manipulation", + }, + { + id: 198, + name: "Foreign", + }, + { + id: 1564, + name: "Estranged Family", + }, + { + id: 171, + name: "Bullying", + }, + { + id: 488, + name: "Age Regression", + }, + { + id: 104, + name: "Anti-Hero", + }, + { + id: 322, + name: "Assassins", + }, + { + id: 774, + name: "Chimera", + }, + { + id: 1045, + name: "Heterosexual", + }, + { + id: 516, + name: "Language Barrier", + }, + { + id: 153, + name: "Time Skip", + }, + ], + type: "MANGA", + format: "MANGA", + status: "FINISHED", + episodes: null, + duration: null, + averageScore: 85, + season: null, + }, + { + id: 176496, + idMal: 58567, + title: { + romaji: + "Ore dake Level Up na Ken: Season 2 - Arise from the Shadow", + english: "Solo Leveling Season 2 -Arise from the Shadow-", + native: + "俺だけレベルアップな件 Season 2 -Arise from the Shadow-", + userPreferred: + "Ore dake Level Up na Ken: Season 2 - Arise from the Shadow", + }, + coverImage: { + large: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx176496-r6oXxEqdZL0n.jpg", + medium: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx176496-r6oXxEqdZL0n.jpg", + color: "#a1bbe4", + }, + bannerImage: null, + genres: ["Action", "Adventure", "Fantasy"], + tags: [ + { + id: 1243, + name: "Necromancy", + }, + { + id: 604, + name: "Dungeon", + }, + ], + type: "ANIME", + format: "TV", + status: "NOT_YET_RELEASED", + episodes: null, + duration: null, + averageScore: null, + season: null, + }, + ], + }); + } + + return HttpResponse.json({ + code: 200, + message: "success", + id: 135643, + idMal: 49210, + id_provider: { + idGogo: "grimm-kumikyoku-dub", + idGogoDub: "grimm-kumikyoku", + idZoro: "the-grimm-variations-19092", + id9anime: "grimm-kumikyoku.qxvzn", + idPahe: "", + }, + title: { + romaji: "Grimm Kumikyoku", + english: "The Grimm Variations", + native: "グリム組曲", + userPreferred: "Grimm Kumikyoku", + }, + dub: true, + description: + 'Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"\n<br><br>\nThe pages of Grimms\' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.\n<br><br>\n(Source: Netflix Anime)', + coverImage: { + large: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg", + medium: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg", + color: "#fea150", + }, + bannerImage: + "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg", + genres: ["Fantasy", "Thriller"], + tags: [ + { + id: 400, + name: "Fairy Tale", + }, + { + id: 193, + name: "Episodic", + }, + { + id: 471, + name: "Anthology", + }, + { + id: 227, + name: "Classic Literature", + }, + { + id: 179, + name: "Witch", + }, + { + id: 1219, + name: "Disability", + }, + { + id: 93, + name: "Post-Apocalyptic", + }, + { + id: 94, + name: "Gore", + }, + { + id: 25, + name: "Historical", + }, + { + id: 250, + name: "Rural", + }, + { + id: 394, + name: "Writing", + }, + { + id: 29, + name: "Magic", + }, + { + id: 161, + name: "Bar", + }, + { + id: 1578, + name: "Arranged Marriage", + }, + { + id: 654, + name: "Denpa", + }, + { + id: 217, + name: "Dystopian", + }, + { + id: 598, + name: "Elf", + }, + { + id: 456, + name: "Conspiracy", + }, + { + id: 63, + name: "Space", + }, + { + id: 364, + name: "Augmented Reality", + }, + { + id: 112, + name: "Virtual World", + }, + { + id: 639, + name: "Body Horror", + }, + { + id: 163, + name: "Yandere", + }, + { + id: 154, + name: "Body Swapping", + }, + { + id: 100, + name: "Nudity", + }, + ], + status: "FINISHED", + format: "ONA", + episodes: 6, + year: 2024, + season: "SPRING", + duration: 44, + startIn: { + year: 2024, + month: 4, + day: 17, + }, + endIn: { + year: 2024, + month: 4, + day: 17, + }, + nextair: null, + score: { + averageScore: 66, + decimalScore: 6.6, + }, + popularity: 8486, + siteUrl: "https://anilist.co/anime/135643", + trailer: { + id: "bTU3detmX_I", + site: "youtube", + thumbnail: "https://i.ytimg.com/vi/bTU3detmX_I/hqdefault.jpg", + }, + studios: [ + { + name: "Netflix", + }, + { + name: "Wit Studio", + }, + ], + relation: [ + { + id: 177039, + idMal: 169338, + title: { + romaji: "Grimm Kumikyoku", + english: null, + native: "グリム組曲", + userPreferred: "Grimm Kumikyoku", + }, + coverImage: { + large: + "https://s4.anilist.co/file/anilistcdn/media/manga/cover/medium/bx177039-672FYniIpHIL.jpg", + medium: + "https://s4.anilist.co/file/anilistcdn/media/manga/cover/small/bx177039-672FYniIpHIL.jpg", + color: "#865028", + }, + bannerImage: null, + genres: ["Fantasy", "Thriller"], + tags: [ + { + id: 400, + name: "Fairy Tale", + }, + { + id: 94, + name: "Gore", + }, + { + id: 85, + name: "Tragedy", + }, + { + id: 63, + name: "Space", + }, + ], + type: "MANGA", + format: "MANGA", + status: "RELEASING", + episodes: null, + duration: null, + averageScore: null, + season: null, + }, + ], + }); + }, + ); +} diff --git a/src/mocks/anify/title.ts b/src/mocks/anify/title.ts new file mode 100644 index 0000000..47e7cac --- /dev/null +++ b/src/mocks/anify/title.ts @@ -0,0 +1,12 @@ +import { HttpResponse, http } from "msw"; + +export function getAnifyTitle() { + return http.get(`https://api.anify.tv/info`, ({ request }) => { + // Construct a URL instance out of the intercepted request. + const url = new URL(request.url); + const id = url.searchParams.get("id"); + + // TODO: Actually return a response + return HttpResponse.json({ bannerImage: null, countryOfOrigin: "JP" }); + }); +} diff --git a/src/mocks/anilist/title.ts b/src/mocks/anilist/title.ts new file mode 100644 index 0000000..9a9b0e1 --- /dev/null +++ b/src/mocks/anilist/title.ts @@ -0,0 +1,70 @@ +import { HttpResponse, graphql } from "msw"; + +export function getAnilistTitle() { + return graphql.query( + "GetTitle", + ({ variables: { id }, request: { headers } }) => { + console.log( + `Intercepting GetTitle query with ID ${id} and Authorization header ${headers.get("authorization")}`, + ); + + if (id === -1 || id === 50) { + return HttpResponse.json({ + errors: [ + { + message: "Not Found.", + status: 404, + locations: [ + { + line: 2, + column: 2, + }, + ], + }, + ], + data: { + Media: null, + }, + }); + } + + return HttpResponse.json({ + data: { + Media: { + id: 135643, + idMal: 49210, + title: { + english: "The Grimm Variations", + userPreferred: "The Grimm Variations", + }, + description: + 'Once upon a time, brothers Jacob and Wilhelm collected fairy tales from across the land and made them into a book. They also had a much younger sister, the innocent and curious Charlotte, who they loved very much. One day, while the brothers were telling Charlotte a fairy tale like usual, they saw that she had a somewhat melancholy look on her face. She asked them, "Do you suppose they really lived happily ever after?"\n<br><br>\nThe pages of Grimms\' Fairy Tales, written by Jacob and Wilhelm, are now presented from the unique perspective of Charlotte, who sees the stories quite differently from her brothers.\n<br><br>\n(Source: Netflix Anime)', + episodes: 6, + genres: ["Fantasy", "Thriller"], + status: "FINISHED", + bannerImage: + "https://s4.anilist.co/file/anilistcdn/media/anime/banner/135643-cmQZCR3z9dB5.jpg", + averageScore: 66, + coverImage: { + extraLarge: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx135643-2kJt86K9Db9P.jpg", + large: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx135643-2kJt86K9Db9P.jpg", + medium: + "https://s4.anilist.co/file/anilistcdn/media/anime/cover/small/bx135643-2kJt86K9Db9P.jpg", + }, + countryOfOrigin: "JP", + mediaListEntry: headers.has("authorization") + ? { + id: 402665918, + progress: 1, + status: "CURRENT", + } + : null, + nextAiringEpisode: null, + }, + }, + }); + }, + ); +} diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 0f3710c..0a8cbfb 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1 +1,5 @@ -export const handlers = []; +import { getAmvstrmTitle } from "./amvstrm/title"; +import { getAnifyTitle } from "./anify/title"; +import { getAnilistTitle } from "./anilist/title"; + +export const handlers = [getAnilistTitle(), getAmvstrmTitle(), getAnifyTitle()]; diff --git a/src/types/schema.ts b/src/types/schema.ts index bcb07ff..6b60d11 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -8,3 +8,11 @@ export const SuccessResponseSchema = <T extends ZodSchema>(schema?: T) => { return z.object({ success: z.literal(true), result: schema }); }; + +export const ErrorResponseSchema = z.object({ + success: z.literal(false), +}); + +export const AniListIdSchema = z + .number({ coerce: true }) + .openapi({ type: "integer" }); diff --git a/src/types/title/countryCodes.ts b/src/types/title/countryCodes.ts new file mode 100644 index 0000000..db2546f --- /dev/null +++ b/src/types/title/countryCodes.ts @@ -0,0 +1,253 @@ +import { z } from "zod"; + +export const countryCodeSchema = z.enum([ + "AD", + "AE", + "AF", + "AG", + "AI", + "AL", + "AM", + "AO", + "AQ", + "AR", + "AS", + "AT", + "AU", + "AW", + "AX", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BL", + "BM", + "BN", + "BO", + "BQ", + "BR", + "BS", + "BT", + "BV", + "BW", + "BY", + "BZ", + "CA", + "CC", + "CD", + "CF", + "CG", + "CH", + "CI", + "CK", + "CL", + "CM", + "CN", + "CO", + "CR", + "CU", + "CV", + "CW", + "CX", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "EH", + "ER", + "ES", + "ET", + "FI", + "FJ", + "FK", + "FM", + "FO", + "FR", + "GA", + "GB", + "GD", + "GE", + "GF", + "GG", + "GH", + "GI", + "GL", + "GM", + "GN", + "GP", + "GQ", + "GR", + "GS", + "GT", + "GU", + "GW", + "GY", + "HK", + "HM", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IM", + "IN", + "IO", + "IQ", + "IR", + "IS", + "IT", + "JE", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KP", + "KR", + "KW", + "KY", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MF", + "MG", + "MH", + "MK", + "ML", + "MM", + "MN", + "MO", + "MP", + "MQ", + "MR", + "MS", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NC", + "NE", + "NF", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NU", + "NZ", + "OM", + "PA", + "PE", + "PF", + "PG", + "PH", + "PK", + "PL", + "PM", + "PN", + "PR", + "PS", + "PT", + "PW", + "PY", + "QA", + "RE", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SD", + "SE", + "SG", + "SH", + "SI", + "SJ", + "SK", + "SL", + "SM", + "SN", + "SO", + "SR", + "SS", + "ST", + "SV", + "SX", + "SY", + "SZ", + "TC", + "TD", + "TF", + "TG", + "TH", + "TJ", + "TK", + "TL", + "TM", + "TN", + "TO", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "UM", + "US", + "UY", + "UZ", + "VA", + "VC", + "VE", + "VG", + "VI", + "VN", + "VU", + "WF", + "WS", + "YE", + "YT", + "ZA", + "ZM", + "ZW", +]); diff --git a/src/types/title/index.ts b/src/types/title/index.ts new file mode 100644 index 0000000..096b0f6 --- /dev/null +++ b/src/types/title/index.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import { countryCodeSchema } from "./countryCodes"; + +export type Title = z.infer<typeof Title>; +export const Title = z.object({ + nextAiringEpisode: z.nullable( + z.object({ + episode: z.number(), + airingAt: z.number(), + timeUntilAiring: z.number(), + }), + ), + mediaListEntry: z.nullable( + z.object({ + status: z.nullable( + z.enum([ + "CURRENT", + "PLANNING", + "COMPLETED", + "DROPPED", + "PAUSED", + "REPEATING", + ]), + ), + progress: z.number().nullable(), + id: z.number(), + }), + ), + countryOfOrigin: z.optional(countryCodeSchema), + coverImage: z.nullable( + z.object({ + medium: z.nullable(z.string()).optional(), + large: z.nullable(z.string()).optional(), + extraLarge: z.nullable(z.string()).optional(), + }), + ), + averageScore: z.number().nullable(), + bannerImage: z.nullable(z.string()), + status: z.nullable( + z.enum([ + "FINISHED", + "RELEASING", + "NOT_YET_RELEASED", + "CANCELLED", + "HIATUS", + ]), + ), + genres: z.nullable(z.array(z.nullable(z.string()))), + episodes: z.number().nullable(), + description: z.nullable(z.string()), + title: z.nullable( + z.object({ + userPreferred: z.nullable(z.string()), + english: z.nullable(z.string()), + }), + ), + idMal: z.number().nullable(), + id: z.number(), +}); diff --git a/tsconfig.json b/tsconfig.json index 0a09870..6043be9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "types": ["@cloudflare/workers-types"], + "types": ["@cloudflare/workers-types", "@types/bun"], "baseUrl": "./", "paths": { "~/*": ["src/*"] @@ -25,6 +25,15 @@ // Some stricter flags "noUnusedLocals": true, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": true + "noPropertyAccessFromIndexSignature": true, + + // plugins + "plugins": [ + { + "name": "@0no-co/graphqlsp", + "schema": "https://graphql.anilist.co", + "tadaOutputLocation": "./src/types/anilist-graphql.d.ts" + } + ] } }