diff --git a/.env.example b/.env.example index c9e59b3..f1c627e 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ ANIWATCH_API_PORT=4000 # env to control allowed origins -ANIWATCH_API_CORS_ALLOWED_ORIGINS=https://your-production-domain.com,https://another-trusted-domain.com +ANIWATCH_API_CORS_ALLOWED_ORIGINS= # RATE LIMIT # duration to track requests (in milliseconds) for rate limiting. here, 30*60*1000 = 1800000 = 30 minutes @@ -12,12 +12,14 @@ ANIWATCH_API_MAX_REQS=70 # CAUTION: # For personal deployments, if you wanna have rate limitting -# in your application, then set this env to your deployed -# instance's hostname, otherwise don't set or have this env at all. -# If you set this env to an incorrect value, you may face other issues. - +# in your application, then set the env below to your deployed +# instance's hostname, otherwise don't set or have it at all. +# If you set the env below to an incorrect value, you may face other issues. # ANIWATCH_API_HOSTNAME="api-aniwatch.onrender.com" # NOTE: this env is "required" for vercel deployments -# ANIWATCH_VERCEL_DEPLOYMENT= +# ANIWATCH_API_VERCEL_DEPLOYMENT= + +# env to use optional redis caching functionality +ANIWATCH_API_REDIS_CONN_URL= diff --git a/README.md b/README.md index e620851..a6e454d 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ #

Aniwatch API

- A free restful API serving anime information from hianime.to -

- + A free RESTful API serving anime information from hianime.to + +
+ +

@@ -56,17 +58,19 @@ > [!IMPORTANT] > -> 1. [https://api-aniwatch.onrender.com](https://api-aniwatch.onrender.com/) is only meant to demo the API and has rate-limiting enabled to minimise bandwidth consumption. It is recommended to deploy your own instance for personal use by customizing the api as you need it to be. -> 2. This API is just an unofficial api for [hianime.to](https://hianime.to) and is in no other way officially related to the same. -> 3. The content that this api provides is not mine, nor is it hosted by me. These belong to their respective owners. This api just demonstrates how to build an api that scrapes websites and uses their content. +> 1. [https://api-aniwatch.onrender.com](https://api-aniwatch.onrender.com/) is only meant to demo the API and has rate-limiting enabled to minimize bandwidth consumption. It is recommended to deploy your own instance for personal use by customizing the API as you need it to be. +> 2. This API is just an unofficial API for [hianime.to](https://hianime.to) and is in no other way officially related to the same. +> 3. The content that this API provides is not mine, nor is it hosted by me. These belong to their respective owners. This API just demonstrates how to build an API that scrapes websites and uses their content. ## Table of Contents - [Installation](#installation) - [Local](#local) - [Docker](#docker) -- [Envs](#envs) -- [Host your instance](#-host-your-instance) +- [Configuration](#️configuration) + - [Custom HTTP Headers](#custom-http-headers) + - [Environment Variables](#environment-variables) +- [Host your instance](#host-your-instance) - [Vercel](#vercel) - [Render](#render) - [Documentation](#documentation) @@ -115,29 +119,36 @@ ### Docker -Docker image is available at [GitHub Container Registry](https://github.com/ghoshRitesh12/aniwatch-api/pkgs/container/aniwatch). +The Docker image is available at [The GitHub Container Registry](https://github.com/ghoshRitesh12/aniwatch-api/pkgs/container/aniwatch). Run the following commands to pull and run the docker image. ```bash -docker pull ghcr.io/ghoshritesh12/aniwatch -docker run -p 4000:4000 ghcr.io/ghoshritesh12/aniwatch +docker run -d --name aniwatch-api -p 4000:4000 ghcr.io/ghoshritesh12/aniwatch ``` -The above command will start the server on port 4000. You can access the server at [http://localhost:4000](http://localhost:4000) and you can also change the port by changing the `-p` option to `-p :4000`. +The above command will start the server on port 4000. You can access the server at [http://localhost:4000](http://localhost:4000), and you can also change the port by changing the `-p` option to `-p :4000`. -You can also add the `-d` flag to run the container in detached mode. +The `-d` flag runs the container in detached mode, and the `--name` flag is used to name the container that's about to run. -## ⚙️ Envs +## ⚙️ Configuration -More info can be found in [`.env.example`](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/.env.example) file +### Custom HTTP Headers +Currently this API supports parsing of only one custom header, and more may be implemented in the future to accommodate varying needs. -- `ANIWATCH_API_PORT`: port number of the aniwatch api -- `ANIWATCH_API_WINDOW_MS`: duration to track requests for rate limitting (in milliseconds) -- `ANIWATCH_API_MAX_REQS`: maximum number of requests in the `ANIWATCH_API_WINDOW_MS` timeperiod -- `ANIWATCH_API_CORS_ALLOWED_ORIGINS`: allowed origins, separated by commas and no spaces in between -- `ANIWATCH_VERCEL_DEPLOYMENT`: required for distinguishing vercel deployment from other ones, set it to true of any other non-zero value -- `ANIWATCH_API_HOSTNAME`: set this to your api instance's hostname to enable rate limitting, don't have this value if you don't wish to rate limit +- `X-ANIWATCH-CACHE-EXPIRY`: this custom header is used to specify the cache expiration duration in **seconds** (defaults to 60 if the header is missing). The `ANIWATCH_API_REDIS_CONN_URL` env is required for this custom header to function as intended; otherwise, there's no point in setting this custom header. + +### Environment Variables + +More info can be found in [`.env.example`](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/.env.example) file, where envs's having a value that is contained within `<` `>` angled brackets are just examples and should be replaced with relevant ones. + +- `ANIWATCH_API_PORT`: port number of the aniwatch API. +- `ANIWATCH_API_WINDOW_MS`: duration to track requests for rate limiting (in milliseconds). +- `ANIWATCH_API_MAX_REQS`: maximum number of requests in the `ANIWATCH_API_WINDOW_MS` time period. +- `ANIWATCH_API_CORS_ALLOWED_ORIGINS`: allowed origins, separated by commas and no spaces in between. +- `ANIWATCH_API_VERCEL_DEPLOYMENT`: required for distinguishing Vercel deployment from other ones; set it to true or any other non-zero value. +- `ANIWATCH_API_HOSTNAME`: set this to your api instance's hostname to enable rate limiting, don't have this value if you don't wish to rate limit. +- `ANIWATCH_API_REDIS_CONN_URL`: this env is optional by default and can be set to utilize Redis caching functionality. It has to be a valid connection URL; otherwise, the Redis client can throw unexpected errors. ## ⛅ Host your instance @@ -145,7 +156,8 @@ More info can be found in [`.env.example`](https://github.com/ghoshRitesh12/aniw > > For personal deployments: > -> - If you wanna have rate limitting in your application, then set the `ANIWATCH_API_HOSTNAME` env to your deployed instance's hostname, otherwise don't set or have this env at all. If you set this env to an incorrect value, you may face other issues. +> - If you want to have rate limiting in your application, then set the `ANIWATCH_API_HOSTNAME` env to your deployed instance's hostname; otherwise, don't set or have this env at all. If you set this env to an incorrect value, you may face other issues. +> - It's optional by default, but if you want to have endpoint response caching functionality, then set the `ANIWATCH_API_REDIS_CONN_URL` env to a valid Redis connection URL. If the connection URL is invalid, the Redis client can throw unexpected errors. > - Remove the if block from the [`server.ts`](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/src/server.ts) file, spanning from lines [71](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/src/server.ts#L71) to [83](https://github.com/ghoshRitesh12/aniwatch-api/blob/main/src/server.ts#L83). ### Vercel @@ -156,7 +168,7 @@ Deploy your own instance of Aniwatch API on Vercel. > [!NOTE] > -> When deploying to vercel, set an env named `ANIWATCH_VERCEL_DEPLOYMENT` to `true` or any non-zero value, but this env must be present. +> When deploying to vercel, set an env named `ANIWATCH_API_VERCEL_DEPLOYMENT` to `true` or any non-zero value, but this env must be present. ### Render diff --git a/package.json b/package.json index 0ca2be3..2a1c462 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,8 @@ "aniwatch": "^2.4.1", "dotenv": "^16.4.5", "hono": "^4.6.3", - "hono-rate-limiter": "^0.4.0" + "hono-rate-limiter": "^0.4.0", + "ioredis": "^5.4.1" }, "devDependencies": { "@types/node": "^22.7.4", diff --git a/src/config/cache.ts b/src/config/cache.ts new file mode 100644 index 0000000..6953fbc --- /dev/null +++ b/src/config/cache.ts @@ -0,0 +1,50 @@ +import { config } from "dotenv"; +import { Redis } from "ioredis"; + +config(); + +export class AniwatchAPICache { + private _client: Redis | null; + public isOptional: boolean = true; + + static DEFAULT_CACHE_EXPIRY_SECONDS = 60 as const; + static CACHE_EXPIRY_HEADER_NAME = "X-ANIWATCH-CACHE-EXPIRY" as const; + + constructor() { + const redisConnURL = process.env?.ANIWATCH_API_REDIS_CONN_URL; + this.isOptional = !Boolean(redisConnURL); + this._client = this.isOptional ? null : new Redis(String(redisConnURL)); + } + + set(key: string | Buffer, value: string | Buffer | number) { + if (this.isOptional) return; + return this._client?.set(key, value); + } + + get(key: string | Buffer) { + if (this.isOptional) return; + return this._client?.get(key); + } + + /** + * @param expirySeconds set to 60 by default + */ + async getOrSet( + key: string | Buffer, + setCB: () => Promise, + expirySeconds: number = AniwatchAPICache.DEFAULT_CACHE_EXPIRY_SECONDS + ) { + const cachedData = this.isOptional + ? null + : (await this._client?.get(key)) || null; + let data = JSON.parse(String(cachedData)) as T; + + if (!data) { + data = await setCB(); + await this._client?.set(key, JSON.stringify(data), "EX", expirySeconds); + } + return data; + } +} + +export const cache = new AniwatchAPICache(); diff --git a/src/config/variables.ts b/src/config/variables.ts new file mode 100644 index 0000000..b58316f --- /dev/null +++ b/src/config/variables.ts @@ -0,0 +1,8 @@ +type CacheVariables = { + CACHE_CONFIG: { + key: string; + duration: number; + }; +}; + +export type AniwatchAPIVariables = {} & CacheVariables; diff --git a/src/routes/hianime.ts b/src/routes/hianime.ts index f4e1ff9..fd34e5d 100644 --- a/src/routes/hianime.ts +++ b/src/routes/hianime.ts @@ -1,123 +1,186 @@ import { Hono } from "hono"; import { HiAnime } from "aniwatch"; +import { cache } from "../config/cache.js"; +import type { AniwatchAPIVariables } from "../config/variables.js"; const hianime = new HiAnime.Scraper(); -const hianimeRouter = new Hono(); +const hianimeRouter = new Hono<{ Variables: AniwatchAPIVariables }>(); // /api/v2/hianime hianimeRouter.get("/", (c) => c.redirect("/", 301)); // /api/v2/hianime/home hianimeRouter.get("/home", async (c) => { - const data = await hianime.getHomePage(); + const cacheConfig = c.get("CACHE_CONFIG"); + + const data = await cache.getOrSet( + cacheConfig.key, + hianime.getHomePage, + cacheConfig.duration + ); + return c.json({ success: true, data }, { status: 200 }); }); // /api/v2/hianime/category/{name}?page={page} hianimeRouter.get("/category/:name", async (c) => { + const cacheConfig = c.get("CACHE_CONFIG"); const categoryName = decodeURIComponent( c.req.param("name").trim() ) as HiAnime.AnimeCategories; - const page: number = Number(decodeURIComponent(c.req.query("page") || "")) || 1; - const data = await hianime.getCategoryAnime(categoryName, page); + const data = await cache.getOrSet( + cacheConfig.key, + async () => hianime.getCategoryAnime(categoryName, page), + cacheConfig.duration + ); + return c.json({ success: true, data }, { status: 200 }); }); // /api/v2/hianime/genre/{name}?page={page} hianimeRouter.get("/genre/:name", async (c) => { + const cacheConfig = c.get("CACHE_CONFIG"); const genreName = decodeURIComponent(c.req.param("name").trim()); const page: number = Number(decodeURIComponent(c.req.query("page") || "")) || 1; - const data = await hianime.getGenreAnime(genreName, page); + const data = await cache.getOrSet( + cacheConfig.key, + async () => hianime.getGenreAnime(genreName, page), + cacheConfig.duration + ); + return c.json({ success: true, data }, { status: 200 }); }); // /api/v2/hianime/producer/{name}?page={page} hianimeRouter.get("/producer/:name", async (c) => { + const cacheConfig = c.get("CACHE_CONFIG"); const producerName = decodeURIComponent(c.req.param("name").trim()); const page: number = Number(decodeURIComponent(c.req.query("page") || "")) || 1; - const data = await hianime.getProducerAnimes(producerName, page); + const data = await cache.getOrSet( + cacheConfig.key, + async () => hianime.getProducerAnimes(producerName, page), + cacheConfig.duration + ); + return c.json({ success: true, data }, { status: 200 }); }); // /api/v2/hianime/schedule?date={date} hianimeRouter.get("/schedule", async (c) => { + const cacheConfig = c.get("CACHE_CONFIG"); const date = decodeURIComponent(c.req.query("date") || ""); - const data = await hianime.getEstimatedSchedule(date); + const data = await cache.getOrSet( + cacheConfig.key, + async () => hianime.getEstimatedSchedule(date), + cacheConfig.duration + ); + return c.json({ success: true, data }, { status: 200 }); }); // /api/v2/hianime/search?q={query}&page={page}&filters={...filters} hianimeRouter.get("/search", async (c) => { + const cacheConfig = c.get("CACHE_CONFIG"); let { q: query, page, ...filters } = c.req.query(); query = decodeURIComponent(query || ""); const pageNo = Number(decodeURIComponent(page || "")) || 1; - const data = await hianime.search(query, pageNo, filters); + const data = await cache.getOrSet( + cacheConfig.key, + async () => hianime.search(query, pageNo, filters), + cacheConfig.duration + ); + return c.json({ success: true, data }, { status: 200 }); }); // /api/v2/hianime/search/suggestion?q={query} hianimeRouter.get("/search/suggestion", async (c) => { + const cacheConfig = c.get("CACHE_CONFIG"); const query = decodeURIComponent(c.req.query("q") || ""); - const data = await hianime.searchSuggestions(query); + const data = await cache.getOrSet( + cacheConfig.key, + async () => hianime.searchSuggestions(query), + cacheConfig.duration + ); + return c.json({ success: true, data }, { status: 200 }); }); // /api/v2/hianime/anime/{animeId} hianimeRouter.get("/anime/:animeId", async (c) => { + const cacheConfig = c.get("CACHE_CONFIG"); const animeId = decodeURIComponent(c.req.param("animeId").trim()); - const data = await hianime.getInfo(animeId); + + const data = await cache.getOrSet( + cacheConfig.key, + async () => hianime.getInfo(animeId), + cacheConfig.duration + ); return c.json({ success: true, data }, { status: 200 }); }); // /api/v2/hianime/episode/servers?animeEpisodeId={id} hianimeRouter.get("/episode/servers", async (c) => { + const cacheConfig = c.get("CACHE_CONFIG"); const animeEpisodeId = decodeURIComponent( c.req.query("animeEpisodeId") || "" ); - const data = await hianime.getEpisodeServers(animeEpisodeId); + const data = await cache.getOrSet( + cacheConfig.key, + async () => hianime.getEpisodeServers(animeEpisodeId), + cacheConfig.duration + ); + return c.json({ success: true, data }, { status: 200 }); }); // episodeId=steinsgate-3?ep=230 // /api/v2/hianime/episode/sources?animeEpisodeId={episodeId}?server={server}&category={category (dub or sub)} hianimeRouter.get("/episode/sources", async (c) => { + const cacheConfig = c.get("CACHE_CONFIG"); const animeEpisodeId = decodeURIComponent( c.req.query("animeEpisodeId") || "" ); const server = decodeURIComponent( c.req.query("server") || HiAnime.Servers.VidStreaming ) as HiAnime.AnimeServers; - const category = decodeURIComponent(c.req.query("category") || "sub") as | "sub" | "dub" | "raw"; - const data = await hianime.getEpisodeSources( - animeEpisodeId, - server, - category + const data = await cache.getOrSet( + cacheConfig.key, + async () => hianime.getEpisodeSources(animeEpisodeId, server, category), + cacheConfig.duration ); + return c.json({ success: true, data }, { status: 200 }); }); // /api/v2/hianime/anime/{anime-id}/episodes hianimeRouter.get("/anime/:animeId/episodes", async (c) => { + const cacheConfig = c.get("CACHE_CONFIG"); const animeId = decodeURIComponent(c.req.param("animeId").trim()); - const data = await hianime.getEpisodes(animeId); + + const data = await cache.getOrSet( + cacheConfig.key, + async () => hianime.getEpisodes(animeId), + cacheConfig.duration + ); return c.json({ success: true, data }, { status: 200 }); }); diff --git a/src/server.ts b/src/server.ts index 6a9c06e..1b69c39 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,12 +6,14 @@ import { ratelimit } from "./config/ratelimit.js"; import { hianimeRouter } from "./routes/hianime.js"; -import { serveStatic } from "@hono/node-server/serve-static"; -import { serve } from "@hono/node-server"; import { Hono } from "hono"; import { logger } from "hono/logger"; +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; import { HiAnimeError } from "aniwatch"; +import { AniwatchAPICache } from "./config/cache.js"; +import type { AniwatchAPIVariables } from "./config/variables.js"; config(); @@ -19,7 +21,7 @@ const BASE_PATH = "/api/v2" as const; const PORT: number = Number(process.env.ANIWATCH_API_PORT) || 4000; const ANIWATCH_API_HOSTNAME = process.env?.ANIWATCH_API_HOSTNAME; -const app = new Hono(); +const app = new Hono<{ Variables: AniwatchAPIVariables }>(); app.use(logger()); app.use(corsConfig); @@ -35,6 +37,20 @@ if (ISNT_PERSONAL_DEPLOYMENT) { app.use("/", serveStatic({ root: "public" })); app.get("/health", (c) => c.text("OK", { status: 200 })); +app.use(async (c, next) => { + const { pathname, search } = new URL(c.req.url); + + c.set("CACHE_CONFIG", { + key: `${pathname.slice(BASE_PATH.length) + search}`, + duration: Number( + c.req.header(AniwatchAPICache.CACHE_EXPIRY_HEADER_NAME) || + AniwatchAPICache.DEFAULT_CACHE_EXPIRY_SECONDS + ), + }); + + await next(); +}); + app.basePath(BASE_PATH).route("/hianime", hianimeRouter); app .basePath(BASE_PATH) @@ -57,7 +73,7 @@ app.onError((err, c) => { }); // NOTE: this env is "required" for vercel deployments -if (!Boolean(process?.env?.ANIWATCH_API_VERCEL_DEPLOYMENT)) { +if (!Boolean(process.env?.ANIWATCH_API_VERCEL_DEPLOYMENT)) { serve({ port: PORT, fetch: app.fetch,