diff --git a/src/apis/extension-store-apis.ts b/src/apis/extension-store-apis.ts new file mode 100644 index 0000000..3326c15 --- /dev/null +++ b/src/apis/extension-store-apis.ts @@ -0,0 +1,36 @@ +import { createApp } from "@aklinker1/zeta"; +import { z } from "zod"; +import { contextPlugin } from "../plugins/context-plugin"; +import { NotFoundHttpError } from "@aklinker1/zeta"; +import { HttpStatus } from "@aklinker1/zeta"; +import { OpenApiTag } from "../enums"; +import { ExtensionStoreNameSchema } from "../models"; + +export const extensionStoreApis = createApp({ + tags: [OpenApiTag.ExtensionStores], +}) + .use(contextPlugin) + .get( + "/api/rest/:storeName/:id/screenshots/:index", + { + operationId: "redirectToScreenshot", + description: + "Redirect to a screenshot's URL from the Chrome Web Store listing", + params: z.object({ + storeName: ExtensionStoreNameSchema, + id: z.string(), + index: z.coerce.number().int().min(0), + }), + }, + async ({ params, stores, set }) => { + const screenshotUrl = await stores[params.storeName].getScreenshotUrl( + params.id, + params.index, + ); + if (!screenshotUrl) + throw new NotFoundHttpError("Extension or screenshot not found"); + + set.status = HttpStatus.Found; + set.headers["Location"] = screenshotUrl; + }, + ); diff --git a/src/routes/grpahql-routes.ts b/src/apis/graphql-apis.ts similarity index 95% rename from src/routes/grpahql-routes.ts rename to src/apis/graphql-apis.ts index 7f25ff6..dc3d387 100644 --- a/src/routes/grpahql-routes.ts +++ b/src/apis/graphql-apis.ts @@ -4,6 +4,7 @@ import { version } from "../../package.json"; import { createGraphql } from "../graphql"; import { z } from "zod"; import dedent from "dedent"; +import { OpenApiTag } from "../enums"; const PLAYGROUND_HTML = (PLAYGROUND_HTML_TEMPLATE as any as string).replace( "{{VERSION}}", @@ -12,12 +13,13 @@ const PLAYGROUND_HTML = (PLAYGROUND_HTML_TEMPLATE as any as string).replace( const graphql = createGraphql(); -export const graphqlRoutes = createApp() +export const graphqlApis = createApp({ + tags: [OpenApiTag.Graphql], +}) .post( "/api", { summary: "Send Query", - tags: ["GraphQL"], description: "Send a query to the GraphQL API. You can play around with queries on the [GraphiQL playground](/playground).", body: z @@ -73,7 +75,6 @@ export const graphqlRoutes = createApp() "/playground", { operationId: "playground", - tags: ["GraphQL"], description: dedent` Open the GraphiQL playground. This is where you can interact and test out the GraphQL API. It also contains the GraphQL documentation explorer. diff --git a/src/apis/system-apis.ts b/src/apis/system-apis.ts new file mode 100644 index 0000000..99071f6 --- /dev/null +++ b/src/apis/system-apis.ts @@ -0,0 +1,31 @@ +import { createApp } from "@aklinker1/zeta"; +import z from "zod"; +import { version } from "../version"; + +export const systemApis = createApp({ + tags: ["System"], +}) + .get( + "/", + { + operationId: "apiDocsRedirect", + summary: "API Docs Redirect", + description: "Redirect to the API reference when visiting the root URL.", + }, + ({ set }) => { + set.status = 302; + set.headers.Location = "/scalar"; + }, + ) + .get( + "/api/health", + { + operationId: "healthCheck", + description: "Used to make sure the API is up and running.", + responses: z.object({ + status: z.literal("ok"), + version: z.string(), + }), + }, + () => ({ status: "ok" as const, version }), + ); diff --git a/src/dependencies.ts b/src/dependencies.ts index f522a4f..fe12364 100644 --- a/src/dependencies.ts +++ b/src/dependencies.ts @@ -1,9 +1,25 @@ import { createIocContainer } from "@aklinker1/zero-ioc"; -import { createChromeService } from "./utils/chrome/chrome-service"; -import { createFirefoxService } from "./utils/firefox/firefox-service"; -import { createEdgeService } from "./utils/edge/edge-service"; +import { createChromeWebStore } from "./services/chrome-web-store"; +import { createFirefoxAddonStore } from "./services/firefox-addon-store"; +import { createEdgeAddonStore } from "./services/edge-addon-store"; +import type { ExtensionStores } from "./services/extension-stores"; +import { ExtensionStoreName } from "./enums"; export const dependencies = createIocContainer() - .register("chrome", createChromeService) - .register("firefox", createFirefoxService) - .register("edge", createEdgeService); + .register("chromeWebStore", createChromeWebStore) + .register("firefoxAddonStore", createFirefoxAddonStore) + .register("edgeAddonStore", createEdgeAddonStore) + .register( + "stores", + (deps) => + ({ + [ExtensionStoreName.ChromeWebStore]: deps.chromeWebStore, + [ExtensionStoreName.FirefoxAddonStore]: deps.firefoxAddonStore, + [ExtensionStoreName.EdgeAddonStore]: deps.edgeAddonStore, + + // Deprecated, but staying around for a while. + [ExtensionStoreName.ChromeExtensions]: deps.chromeWebStore, + [ExtensionStoreName.FirefoxExtensions]: deps.firefoxAddonStore, + [ExtensionStoreName.EdgeExtensions]: deps.edgeAddonStore, + }) satisfies ExtensionStores, + ); diff --git a/src/enums.ts b/src/enums.ts new file mode 100644 index 0000000..0658ff5 --- /dev/null +++ b/src/enums.ts @@ -0,0 +1,18 @@ +export enum ExtensionStoreName { + ChromeWebStore = "chrome-web-store", + FirefoxAddonStore = "firefox-addon-store", + EdgeAddonStore = "edge-addon-store", + + /** @deprecated Use {@link ChromeWebStore} instead. */ + ChromeExtensions = "chrome-extensions", + /** @deprecated Use {@link FirefoxAddonStore} instead. */ + FirefoxExtensions = "firefox-extensions", + /** @deprecated Use {@link EdgeAddonStore} instead. */ + EdgeExtensions = "edge-extensions", +} + +export enum OpenApiTag { + System = "System", + ExtensionStores = "Extension Stores", + Graphql = "GraphQL", +} diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index bd0c76d..101f38c 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -1,8 +1,8 @@ export const rootResolver: Gql.RootResolver = { - chromeExtension: ({ id }, ctx) => ctx.chrome.getExtension(id), - chromeExtensions: ({ ids }, ctx) => ctx.chrome.getExtensions(ids), - firefoxAddon: ({ id }, ctx) => ctx.firefox.getAddon(id), - firefoxAddons: ({ ids }, ctx) => ctx.firefox.getAddons(ids), - edgeAddon: ({ id }, ctx) => ctx.edge.getAddon(id), - edgeAddons: ({ ids }, ctx) => ctx.edge.getAddons(ids), + chromeExtension: ({ id }, ctx) => ctx.chromeWebStore.getExtension(id), + chromeExtensions: ({ ids }, ctx) => ctx.chromeWebStore.getExtensions(ids), + firefoxAddon: ({ id }, ctx) => ctx.firefoxAddonStore.getExtension(id), + firefoxAddons: ({ ids }, ctx) => ctx.firefoxAddonStore.getExtensions(ids), + edgeAddon: ({ id }, ctx) => ctx.edgeAddonStore.getExtension(id), + edgeAddons: ({ ids }, ctx) => ctx.edgeAddonStore.getExtensions(ids), }; diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..e2ed5cc --- /dev/null +++ b/src/models.ts @@ -0,0 +1,6 @@ +import z from "zod"; +import { ExtensionStoreName } from "./enums"; + +export const ExtensionStoreNameSchema = z + .enum(ExtensionStoreName) + .meta({ ref: "ExtensionStoreName" }); diff --git a/src/routes/rest-routes.ts b/src/routes/rest-routes.ts deleted file mode 100644 index 1430eca..0000000 --- a/src/routes/rest-routes.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { createApp } from "@aklinker1/zeta"; -import { z } from "zod"; -import { contextPlugin } from "../plugins/context-plugin"; -import { NotFoundHttpError } from "@aklinker1/zeta"; -import { HttpStatus } from "@aklinker1/zeta"; - -export const restRoutes = createApp() - .use(contextPlugin) - .get( - "/api/rest/chrome-extensions/:id/screenshots/:index", - { - operationId: "chromeScreenshotRedirect", - tags: ["Chrome Extensions"], - description: - "Redirect to a screenshot's URL from the Chrome Web Store listing", - params: z.object({ - id: z.string(), - index: z.coerce.number().int().min(0), - }), - }, - async ({ params, chrome, set }) => { - const screenshotUrl = await chrome.getScreenshotUrl( - params.id, - params.index, - ); - if (!screenshotUrl) - throw new NotFoundHttpError("Extension or screenshot not found"); - - set.status = HttpStatus.Found; - set.headers["Location"] = screenshotUrl; - }, - ) - .get( - "/api/rest/firefox-addons/:addonId/screenshots/:index", - { - operationId: "firefoxScreenshotRedirect", - tags: ["Firefox Addons"], - description: - "Redirect to a screenshot's URL from the Firefox Addons listing.", - params: z.object({ - addonId: z.string(), - index: z.coerce.number().int().min(0), - }), - }, - async ({ params, firefox, set }) => { - const screenshotUrl = await firefox.getScreenshotUrl( - params.addonId, - params.index, - ); - if (!screenshotUrl) - throw new NotFoundHttpError("Extension or screenshot not found"); - - set.status = HttpStatus.Found; - set.headers["Location"] = screenshotUrl; - }, - ) - .get( - "/api/rest/edge-addons/:addonId/screenshots/:index", - { - operationId: "edgeScreenshotRedirect", - tags: ["Firefox Addons"], - description: - "Redirect to a screenshot's URL from the Edge Addons listing.", - params: z.object({ - addonId: z.string(), - index: z.coerce.number().int().min(0), - }), - }, - async ({ params, edge, set }) => { - const screenshotUrl = await edge.getScreenshotUrl( - params.addonId, - params.index, - ); - if (!screenshotUrl) - throw new NotFoundHttpError("Extension or screenshot not found"); - - set.status = HttpStatus.Found; - set.headers["Location"] = screenshotUrl; - }, - ); diff --git a/src/server.ts b/src/server.ts index cf48cda..6d352bc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,12 +1,13 @@ import consola from "consola"; import { createApp } from "@aklinker1/zeta"; import { corsPlugin } from "./plugins/cors-plugin"; -import { graphqlRoutes } from "./routes/grpahql-routes"; -import { restRoutes } from "./routes/rest-routes"; +import { graphqlApis } from "./apis/graphql-apis"; +import { extensionStoreApis } from "./apis/extension-store-apis"; import { zodSchemaAdapter } from "@aklinker1/zeta/adapters/zod-schema-adapter"; -import { version } from "../package.json"; -import { z } from "zod"; +import { version } from "./version"; import dedent from "dedent"; +import { systemApis } from "./apis/system-apis"; +import { OpenApiTag } from "./enums"; const app = createApp({ schemaAdapter: zodSchemaAdapter, @@ -35,47 +36,22 @@ const app = createApp({ }, tags: [ { - name: "GraphQL", + name: OpenApiTag.Graphql, description: dedent` To play around with the GraphQL API, checkout the [GraphiQL Playground](/playground). `, }, - { name: "Chrome Extensions" }, - { name: "Firefox Addons" }, - { name: "System" }, + { name: OpenApiTag.ExtensionStores }, + { name: OpenApiTag.System }, ], }, }) .onGlobalError(({ error }) => void consola.error(error)) .use(corsPlugin) - .use(restRoutes) - .use(graphqlRoutes) - .get( - "/", - { - summary: "API Docs Redirect", - tags: ["System"], - description: "Redirect to the API reference when visiting the root URL.", - }, - ({ set }) => { - set.status = 302; - set.headers.Location = "/scalar"; - }, - ) - .get( - "/api/health", - { - summary: "Health Check", - tags: ["System"], - description: "Used to make sure the API is up and running.", - responses: z.object({ - status: z.literal("ok"), - version: z.string(), - }), - }, - () => ({ status: "ok" as const, version }), - ); + .use(systemApis) + .use(extensionStoreApis) + .use(graphqlApis); export default app; export type App = typeof app; diff --git a/src/utils/chrome/__tests__/__snapshots__/chrome-crawler.test.ts.snap b/src/services/__tests__/__snapshots__/chrome-crawler.test.ts.snap similarity index 79% rename from src/utils/chrome/__tests__/__snapshots__/chrome-crawler.test.ts.snap rename to src/services/__tests__/__snapshots__/chrome-crawler.test.ts.snap index 2531bab..5bb8029 100644 --- a/src/utils/chrome/__tests__/__snapshots__/chrome-crawler.test.ts.snap +++ b/src/services/__tests__/__snapshots__/chrome-crawler.test.ts.snap @@ -22,57 +22,57 @@ AI Integration: Extract and convert relevant website information into markdown f "screenshots": [ { "index": 5, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/5", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/5", "rawUrl": "https://lh3.googleusercontent.com/SwOe0XmN3ZkCkJQ4IF1a1i2qqCsw0b7I-Ez6VSQflAvafwiJhGbdRW6YAKNX8fAwdv159XogYSFc5Uykgt1fsYSv=s1280", }, { "index": 1, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/1", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/1", "rawUrl": "https://lh3.googleusercontent.com/vDnkawI_rgAd5ou0gasjR5CQbm-HmbTj9SBTdlpxU_-ri1jeE9dz0qNnSOijaboiUd4tuwQBGg-ujIO-znDN4-InVZA=s1280", }, { "index": 2, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/2", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/2", "rawUrl": "https://lh3.googleusercontent.com/F9H2EBxfqxrriMb0zdjoFIiCufav1oV8QI-enLqT9AEuL0nPFJLaj0286R2UH_ekxLoVa-yO76f5sSDOAjEuORrRLg=s1280", }, { "index": 3, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/3", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/3", "rawUrl": "https://lh3.googleusercontent.com/kkK_WlsYeZNGK-sU-4nvH-sEXtx7xUMuqpsK-IHq29IUkmyC3C40n1DDsDdt0f6-LgbKGL_obH_Cse-zJWJPHk-OLQ=s1280", }, { "index": 4, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/4", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/4", "rawUrl": "https://lh3.googleusercontent.com/VJy9hYOMvlmwp2FhtrpIm8hNwwJVZWl-V4dZVl2cCl4aD8xNyuHaEgdm5bfjMBiZJXI0ysxul8P1GAyFaJrRvjDSuMY=s1280", }, { "index": 5, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/5", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/5", "rawUrl": "https://lh3.googleusercontent.com/SwOe0XmN3ZkCkJQ4IF1a1i2qqCsw0b7I-Ez6VSQflAvafwiJhGbdRW6YAKNX8fAwdv159XogYSFc5Uykgt1fsYSv=s1280", }, { "index": 1, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/1", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/1", "rawUrl": "https://lh3.googleusercontent.com/vDnkawI_rgAd5ou0gasjR5CQbm-HmbTj9SBTdlpxU_-ri1jeE9dz0qNnSOijaboiUd4tuwQBGg-ujIO-znDN4-InVZA=s1280", }, { "index": 2, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/2", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/2", "rawUrl": "https://lh3.googleusercontent.com/F9H2EBxfqxrriMb0zdjoFIiCufav1oV8QI-enLqT9AEuL0nPFJLaj0286R2UH_ekxLoVa-yO76f5sSDOAjEuORrRLg=s1280", }, { "index": 3, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/3", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/3", "rawUrl": "https://lh3.googleusercontent.com/kkK_WlsYeZNGK-sU-4nvH-sEXtx7xUMuqpsK-IHq29IUkmyC3C40n1DDsDdt0f6-LgbKGL_obH_Cse-zJWJPHk-OLQ=s1280", }, { "index": 4, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/4", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/4", "rawUrl": "https://lh3.googleusercontent.com/VJy9hYOMvlmwp2FhtrpIm8hNwwJVZWl-V4dZVl2cCl4aD8xNyuHaEgdm5bfjMBiZJXI0ysxul8P1GAyFaJrRvjDSuMY=s1280", }, { "index": 5, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/5", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/kofbbilhmnkcmibjbioafflgmpkbnmme/screenshots/5", "rawUrl": "https://lh3.googleusercontent.com/SwOe0XmN3ZkCkJQ4IF1a1i2qqCsw0b7I-Ez6VSQflAvafwiJhGbdRW6YAKNX8fAwdv159XogYSFc5Uykgt1fsYSv=s1280", }, ], @@ -107,12 +107,12 @@ https://github.com/aklinker1/github-better-line-counts" "screenshots": [ { "index": 0, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/ocfdgncpifmegplaglcnglhioflaimkd/screenshots/0", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/ocfdgncpifmegplaglcnglhioflaimkd/screenshots/0", "rawUrl": "https://lh3.googleusercontent.com/GUgh0ThX2FDPNvbaumYl4DqsUhsbYiCe-Hut9FoVEnkmTrXyA-sHbMk5jmZTj_t-dDP8rAmy6X6a6GNTCn9F8zo4VYU=s1280", }, { "index": 1, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/ocfdgncpifmegplaglcnglhioflaimkd/screenshots/1", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/ocfdgncpifmegplaglcnglhioflaimkd/screenshots/1", "rawUrl": "https://lh3.googleusercontent.com/qRi-kO0il8W6CnWa_-7oFzCwWKwr73w607I-rpYF9MM27omsuoN0k4dkgBbBECD3vZszdTSkQnoW9sywsfvAQ_7M9Q=s1280", }, ], @@ -162,57 +162,57 @@ Absolutely! Blync is 100% secure. "screenshots": [ { "index": 5, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/5", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/5", "rawUrl": "https://lh3.googleusercontent.com/MXf271aHeP9deq2VjO0-_QDU-VSKgu81hcc9iKNMbAg74sI1RnvOb7Esgy95P-NTrQ7vVJtW2fMOPnTYv8pnbnMEng=s1280", }, { "index": 1, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/1", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/1", "rawUrl": "https://lh3.googleusercontent.com/WVvtpr2Wzfr4zT34Z_Gy39ZL-I8UOZ5uReOAz-vmtC8lT4hebXlnur7y0bAWzXd6xihMbTP_PfZgQN_C4MfhixI2=s1280", }, { "index": 2, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/2", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/2", "rawUrl": "https://lh3.googleusercontent.com/e3JEIuiGu_dI70zGBPBXzL9YNZuHy8nItCoEKJSql56CcUVyIQC9Lor8HkeScWE2-qAFw5DLZQijXEfNxlInSoLl0DE=s1280", }, { "index": 3, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/3", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/3", "rawUrl": "https://lh3.googleusercontent.com/O9OuxL28A3GOp5-R8u2kyV9wkOchMTx0ZNWraAz93gqThZRwVpCbaEW0VL1bZ0dKaKr0ReN0qJYJfSFAPOkCuOGA=s1280", }, { "index": 4, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/4", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/4", "rawUrl": "https://lh3.googleusercontent.com/vfXSijB8FQ4hkARrKdJJfXe3b_ahd8yXsSuAbbKNwU9yondg0SIj1fenPJRosimBMo8bi1hj99lypD1CS1y1KhJE=s1280", }, { "index": 5, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/5", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/5", "rawUrl": "https://lh3.googleusercontent.com/MXf271aHeP9deq2VjO0-_QDU-VSKgu81hcc9iKNMbAg74sI1RnvOb7Esgy95P-NTrQ7vVJtW2fMOPnTYv8pnbnMEng=s1280", }, { "index": 1, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/1", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/1", "rawUrl": "https://lh3.googleusercontent.com/WVvtpr2Wzfr4zT34Z_Gy39ZL-I8UOZ5uReOAz-vmtC8lT4hebXlnur7y0bAWzXd6xihMbTP_PfZgQN_C4MfhixI2=s1280", }, { "index": 2, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/2", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/2", "rawUrl": "https://lh3.googleusercontent.com/e3JEIuiGu_dI70zGBPBXzL9YNZuHy8nItCoEKJSql56CcUVyIQC9Lor8HkeScWE2-qAFw5DLZQijXEfNxlInSoLl0DE=s1280", }, { "index": 3, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/3", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/3", "rawUrl": "https://lh3.googleusercontent.com/O9OuxL28A3GOp5-R8u2kyV9wkOchMTx0ZNWraAz93gqThZRwVpCbaEW0VL1bZ0dKaKr0ReN0qJYJfSFAPOkCuOGA=s1280", }, { "index": 4, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/4", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/4", "rawUrl": "https://lh3.googleusercontent.com/vfXSijB8FQ4hkARrKdJJfXe3b_ahd8yXsSuAbbKNwU9yondg0SIj1fenPJRosimBMo8bi1hj99lypD1CS1y1KhJE=s1280", }, { "index": 5, - "indexUrl": "http://localhost:3000/api/rest/chrome-extensions/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/5", + "indexUrl": "http://localhost:3000/api/rest/chrome-web-store/odffpjnpocjfcaclnenaaaddghkgijdb/screenshots/5", "rawUrl": "https://lh3.googleusercontent.com/MXf271aHeP9deq2VjO0-_QDU-VSKgu81hcc9iKNMbAg74sI1RnvOb7Esgy95P-NTrQ7vVJtW2fMOPnTYv8pnbnMEng=s1280", }, ], diff --git a/src/utils/chrome/__tests__/chrome-crawler.e2e.test.ts b/src/services/__tests__/chrome-crawler.e2e.test.ts similarity index 86% rename from src/utils/chrome/__tests__/chrome-crawler.e2e.test.ts rename to src/services/__tests__/chrome-crawler.e2e.test.ts index 9c79d22..4a3e454 100644 --- a/src/utils/chrome/__tests__/chrome-crawler.e2e.test.ts +++ b/src/services/__tests__/chrome-crawler.e2e.test.ts @@ -29,13 +29,13 @@ describe("Chrome Web Store Crawler E2E", () => { { index: 0, indexUrl: - "http://localhost:3000/api/rest/chrome-extensions/ocfdgncpifmegplaglcnglhioflaimkd/screenshots/0", + "http://localhost:3000/api/rest/chrome-web-store/ocfdgncpifmegplaglcnglhioflaimkd/screenshots/0", rawUrl: expect.any(String), }, { index: 1, indexUrl: - "http://localhost:3000/api/rest/chrome-extensions/ocfdgncpifmegplaglcnglhioflaimkd/screenshots/1", + "http://localhost:3000/api/rest/chrome-web-store/ocfdgncpifmegplaglcnglhioflaimkd/screenshots/1", rawUrl: expect.any(String), }, ], diff --git a/src/utils/chrome/__tests__/chrome-crawler.test.ts b/src/services/__tests__/chrome-crawler.test.ts similarity index 100% rename from src/utils/chrome/__tests__/chrome-crawler.test.ts rename to src/services/__tests__/chrome-crawler.test.ts diff --git a/src/utils/chrome/__tests__/fixtures/chrome-web-store/.new/.keep b/src/services/__tests__/fixtures/chrome-web-store/.new/.keep similarity index 100% rename from src/utils/chrome/__tests__/fixtures/chrome-web-store/.new/.keep rename to src/services/__tests__/fixtures/chrome-web-store/.new/.keep diff --git a/src/utils/chrome/__tests__/fixtures/chrome-web-store/2025-02-26-kofbbilhmnkcmibjbioafflgmpkbnmme.html b/src/services/__tests__/fixtures/chrome-web-store/2025-02-26-kofbbilhmnkcmibjbioafflgmpkbnmme.html similarity index 100% rename from src/utils/chrome/__tests__/fixtures/chrome-web-store/2025-02-26-kofbbilhmnkcmibjbioafflgmpkbnmme.html rename to src/services/__tests__/fixtures/chrome-web-store/2025-02-26-kofbbilhmnkcmibjbioafflgmpkbnmme.html diff --git a/src/utils/chrome/__tests__/fixtures/chrome-web-store/2025-02-26-oadbjpccljkplmhnjekgjamejnbadlne.html b/src/services/__tests__/fixtures/chrome-web-store/2025-02-26-oadbjpccljkplmhnjekgjamejnbadlne.html similarity index 100% rename from src/utils/chrome/__tests__/fixtures/chrome-web-store/2025-02-26-oadbjpccljkplmhnjekgjamejnbadlne.html rename to src/services/__tests__/fixtures/chrome-web-store/2025-02-26-oadbjpccljkplmhnjekgjamejnbadlne.html diff --git a/src/utils/chrome/__tests__/fixtures/chrome-web-store/2025-02-26-ocfdgncpifmegplaglcnglhioflaimkd.html b/src/services/__tests__/fixtures/chrome-web-store/2025-02-26-ocfdgncpifmegplaglcnglhioflaimkd.html similarity index 100% rename from src/utils/chrome/__tests__/fixtures/chrome-web-store/2025-02-26-ocfdgncpifmegplaglcnglhioflaimkd.html rename to src/services/__tests__/fixtures/chrome-web-store/2025-02-26-ocfdgncpifmegplaglcnglhioflaimkd.html diff --git a/src/utils/chrome/__tests__/fixtures/chrome-web-store/2025-02-26-odffpjnpocjfcaclnenaaaddghkgijdb.html b/src/services/__tests__/fixtures/chrome-web-store/2025-02-26-odffpjnpocjfcaclnenaaaddghkgijdb.html similarity index 100% rename from src/utils/chrome/__tests__/fixtures/chrome-web-store/2025-02-26-odffpjnpocjfcaclnenaaaddghkgijdb.html rename to src/services/__tests__/fixtures/chrome-web-store/2025-02-26-odffpjnpocjfcaclnenaaaddghkgijdb.html diff --git a/src/utils/chrome/chrome-crawler.ts b/src/services/chrome-crawler.ts similarity index 96% rename from src/utils/chrome/chrome-crawler.ts rename to src/services/chrome-crawler.ts index 7e7a14b..07901e0 100644 --- a/src/utils/chrome/chrome-crawler.ts +++ b/src/services/chrome-crawler.ts @@ -1,6 +1,7 @@ import consola from "consola"; import { HTMLAnchorElement, HTMLElement, parseHTML } from "linkedom"; -import { buildScreenshotUrl } from "../urls"; +import { buildScreenshotUrl } from "../utils/urls"; +import { ExtensionStoreName } from "../enums"; export async function crawlExtension( id: string, @@ -140,7 +141,11 @@ export async function crawlExtension( return { index, rawUrl: div.getAttribute("data-media-url") + "=s1280", // "s1280" gets the full resolution - indexUrl: buildScreenshotUrl("chrome-extensions", id, index), + indexUrl: buildScreenshotUrl( + ExtensionStoreName.ChromeWebStore, + id, + index, + ), }; }), ]); diff --git a/src/services/chrome-web-store.ts b/src/services/chrome-web-store.ts new file mode 100644 index 0000000..2a2878d --- /dev/null +++ b/src/services/chrome-web-store.ts @@ -0,0 +1,8 @@ +import { crawlExtension } from "./chrome-crawler"; +import { defineExtensionStore, type ExtensionStore } from "./extension-store"; + +export type ChromeWebStore = ExtensionStore; + +export function createChromeWebStore() { + return defineExtensionStore((id) => crawlExtension(String(id), "en")); +} diff --git a/src/services/edge-addon-store.ts b/src/services/edge-addon-store.ts new file mode 100644 index 0000000..b47a72f --- /dev/null +++ b/src/services/edge-addon-store.ts @@ -0,0 +1,10 @@ +import { createEdgeApi } from "./edge-api"; +import { defineExtensionStore, type ExtensionStore } from "./extension-store"; + +export type EdgeAddonStore = ExtensionStore; + +export function createEdgeAddonStore() { + const api = createEdgeApi(); + + return defineExtensionStore((id) => api.getAddon(String(id))); +} diff --git a/src/utils/edge/edge-api.ts b/src/services/edge-api.ts similarity index 91% rename from src/utils/edge/edge-api.ts rename to src/services/edge-api.ts index c605a01..6ac3c09 100644 --- a/src/utils/edge/edge-api.ts +++ b/src/services/edge-api.ts @@ -1,4 +1,5 @@ -import { buildScreenshotUrl } from "../urls"; +import { ExtensionStoreName } from "../enums"; +import { buildScreenshotUrl } from "../utils/urls"; export interface EdgeApi { getAddon(crxid: string): Promise; @@ -22,7 +23,11 @@ export function createEdgeApi(): EdgeApi { storeUrl: `https://microsoftedge.microsoft.com/addons/detail/${res.crxId}`, screenshots: res.screenshots.map((ss, i) => ({ index: i, - indexUrl: buildScreenshotUrl("edge-addons", res.crxId, i), + indexUrl: buildScreenshotUrl( + ExtensionStoreName.EdgeAddonStore, + res.crxId, + i, + ), rawUrl: `https:${ss.uri}`, // URL without the schema (ex: "//store-images.s-microsoft.com/image/...") })), }); diff --git a/src/services/extension-store.ts b/src/services/extension-store.ts new file mode 100644 index 0000000..c126e6a --- /dev/null +++ b/src/services/extension-store.ts @@ -0,0 +1,74 @@ +import { createCachedDataLoader } from "../utils/cache"; +import { HOUR_MS } from "../utils/time"; + +export type ExtensionId = string | number; + +export interface ExtensionStore { + /** + * Get an extension by it's ID. + */ + getExtension: ( + extensionId: ExtensionId, + ) => Promise; + + /** + * Get multiple extensions by their IDs. + */ + getExtensions: ( + extensionIds: ExtensionId[], + ) => Promise<(TGqlExtension | undefined)[]>; + + /** + * Get a screenshot given an index. + */ + getScreenshotUrl( + extensionId: ExtensionId, + screenshotIndex: number, + ): Promise; +} + +export function defineExtensionStore( + fetch: (id: ExtensionId) => Promise, +): ExtensionStore { + const loader = createCachedDataLoader( + HOUR_MS, + async (ids) => { + const results = await Promise.allSettled(ids.map((id) => fetch(id))); + return results.map((res) => + res.status === "fulfilled" ? res.value : undefined, + ); + }, + ); + + const getExtension: ExtensionStore["getExtension"] = ( + extensionId, + ) => loader.load(extensionId); + + const getExtensions: ExtensionStore["getExtensions"] = async ( + extensionIds, + ) => { + const result = await loader.loadMany(extensionIds); + return result.map((item, index) => { + if (item instanceof Error) { + console.warn("Error loading extension:", extensionIds[index], item); + return undefined; + } + return item; + }); + }; + + const getScreenshotUrl: ExtensionStore["getScreenshotUrl"] = + async (extensionId, screenshotIndex) => { + const extension = await getExtension(extensionId); + const screenshot = extension?.screenshots.find( + (screenshot) => screenshot.index == screenshotIndex, + ); + return screenshot?.rawUrl; + }; + + return { + getExtension, + getExtensions, + getScreenshotUrl, + }; +} diff --git a/src/services/extension-stores.ts b/src/services/extension-stores.ts new file mode 100644 index 0000000..8ddcf3e --- /dev/null +++ b/src/services/extension-stores.ts @@ -0,0 +1,4 @@ +import type { ExtensionStoreName } from "../enums"; +import type { ExtensionStore } from "./extension-store"; + +export type ExtensionStores = Record>; diff --git a/src/services/firefox-addon-store.ts b/src/services/firefox-addon-store.ts new file mode 100644 index 0000000..3faa966 --- /dev/null +++ b/src/services/firefox-addon-store.ts @@ -0,0 +1,10 @@ +import { createFirefoxApi } from "./firefox-api"; +import { defineExtensionStore, type ExtensionStore } from "./extension-store"; + +export type FirefoxAddonStore = ExtensionStore; + +export function createFirefoxAddonStore() { + const api = createFirefoxApi(); + + return defineExtensionStore((id) => api.getAddon(id)); +} diff --git a/src/services/firefox-api.ts b/src/services/firefox-api.ts new file mode 100644 index 0000000..f2e17b7 --- /dev/null +++ b/src/services/firefox-api.ts @@ -0,0 +1,54 @@ +import consola from "consola"; +import { buildScreenshotUrl } from "../utils/urls"; +import { ExtensionStoreName } from "../enums"; + +export interface FirefoxApi { + getAddon(idOrSlugOrGuid: number | string): Promise; +} + +export function createFirefoxApi(): FirefoxApi { + const toGqlFirefoxAddon = (res: any): Gql.FirefoxAddon => ({ + id: res.id, + iconUrl: res.icon_url, + lastUpdated: res.last_updated, + longDescription: Object.values(res.description)[0]!, + name: Object.values(res.name)[0]!, + rating: res.ratings.average, + reviewCount: res.ratings.count, + shortDescription: Object.values(res.summary)[0]!, + storeUrl: res.url, + version: res.current_version.version, + users: res.average_daily_users, + dailyActiveUsers: res.average_daily_users, + screenshots: (res.previews as any[]).map((preview, i) => ({ + index: i, + rawUrl: preview.image_url, + indexUrl: buildScreenshotUrl( + ExtensionStoreName.FirefoxAddonStore, + res.id, + i, + ), + })), + }); + + const getAddon: FirefoxApi["getAddon"] = async ( + idOrSlugOrGuid: number | string, + ): Promise => { + consola.info("Fetching " + idOrSlugOrGuid); + const url = new URL( + `https://addons.mozilla.org/api/v5/addons/addon/${idOrSlugOrGuid}`, + ); + const res = await fetch(url); + if (res.status !== 200) + throw Error( + `${url.href} failed with status: ${res.status} ${res.statusText}`, + ); + + const json = await res.json(); + return toGqlFirefoxAddon(json); + }; + + return { + getAddon, + }; +} diff --git a/src/utils/chrome/chrome-service.ts b/src/utils/chrome/chrome-service.ts deleted file mode 100644 index 7a60c9f..0000000 --- a/src/utils/chrome/chrome-service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { crawlExtension } from "./chrome-crawler"; -import { createCachedDataLoader } from "../cache"; -import { HOUR_MS } from "../time"; - -export interface ChromeService { - getExtension: ( - extensionId: string, - ) => Promise; - getExtensions: ( - extensionIds: string[], - ) => Promise>; - getScreenshotUrl( - extensionId: string, - screenshotIndex: number, - ): Promise; -} - -export function createChromeService(): ChromeService { - const loader = createCachedDataLoader< - string, - Gql.ChromeExtension | undefined - >(HOUR_MS, async (ids) => { - const results = await Promise.allSettled( - ids.map((id) => crawlExtension(id, "en")), - ); - return results.map((res) => - res.status === "fulfilled" ? res.value : res.reason, - ); - }); - - const getExtension: ChromeService["getExtension"] = (extensionId) => - loader.load(extensionId); - - const getExtensions: ChromeService["getExtensions"] = async ( - extensionIds, - ) => { - const result = await loader.loadMany(extensionIds); - return result.map((item, index) => { - if (item instanceof Error) { - console.warn("Error loading extension:", extensionIds[index], item); - return undefined; - } - return item; - }); - }; - - const getScreenshotUrl: ChromeService["getScreenshotUrl"] = async ( - extensionId, - screenshotIndex, - ) => { - const extension = await getExtension(extensionId); - const screenshot = extension?.screenshots.find( - (screenshot) => screenshot.index == screenshotIndex, - ); - return screenshot?.rawUrl; - }; - - return { - getExtension, - getExtensions, - getScreenshotUrl, - }; -} diff --git a/src/utils/edge/edge-service.ts b/src/utils/edge/edge-service.ts deleted file mode 100644 index 09ed314..0000000 --- a/src/utils/edge/edge-service.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createCachedDataLoader } from "../cache"; -import { HOUR_MS } from "../time"; -import { createEdgeApi } from "./edge-api"; - -export interface EdgeService { - getAddon: (id: string) => Promise; - getAddons: (ids: string[]) => Promise>; - getScreenshotUrl: ( - addonId: string, - screenshotIndex: number, - ) => Promise; -} - -export function createEdgeService(): EdgeService { - const api = createEdgeApi(); - - const loader = createCachedDataLoader( - HOUR_MS, - (ids) => Promise.all(ids.map((id) => api.getAddon(id))), - ); - - const getAddon: EdgeService["getAddon"] = (id) => loader.load(id); - - const getAddons: EdgeService["getAddons"] = async (ids) => { - const result = await loader.loadMany(ids); - return result.map((item) => { - if (item == null) return undefined; - if (item instanceof Error) { - console.warn("Error fetching multiple addons:", item); - return undefined; - } - return item; - }); - }; - - const getScreenshotUrl: EdgeService["getScreenshotUrl"] = async ( - addonId, - screenshotIndex, - ) => { - const addon = await getAddon(addonId); - const screenshot = addon?.screenshots.find( - (screenshot) => screenshot.index == screenshotIndex, - ); - return screenshot?.rawUrl; - }; - - return { - getAddon, - getAddons, - getScreenshotUrl, - }; -} diff --git a/src/utils/firefox/firefox-api.ts b/src/utils/firefox/firefox-api.ts deleted file mode 100644 index 2852c05..0000000 --- a/src/utils/firefox/firefox-api.ts +++ /dev/null @@ -1,44 +0,0 @@ -import consola from "consola"; -import { buildScreenshotUrl } from "../urls"; - -export function createFirefoxApiClient() { - return { - getAddon: async ( - idOrSlugOrGuid: number | string, - ): Promise => { - consola.info("Fetching " + idOrSlugOrGuid); - const url = new URL( - `https://addons.mozilla.org/api/v5/addons/addon/${idOrSlugOrGuid}`, - ); - const res = await fetch(url); - if (res.status !== 200) - throw Error( - `${url.href} failed with status: ${res.status} ${res.statusText}`, - ); - - const json: any = await res.json(); - - return { - id: json.id, - iconUrl: json.icon_url, - lastUpdated: json.last_updated, - longDescription: Object.values(json.description)[0]!, - name: Object.values(json.name)[0]!, - rating: json.ratings.average, - reviewCount: json.ratings.count, - shortDescription: Object.values(json.summary)[0]!, - storeUrl: json.url, - version: json.current_version.version, - users: json.average_daily_users, - dailyActiveUsers: json.average_daily_users, - screenshots: (json.previews as any[]).map( - (preview, i) => ({ - index: i, - rawUrl: preview.image_url, - indexUrl: buildScreenshotUrl("firefox-addons", json.id, i), - }), - ), - }; - }, - }; -} diff --git a/src/utils/firefox/firefox-service.ts b/src/utils/firefox/firefox-service.ts deleted file mode 100644 index e7bb68a..0000000 --- a/src/utils/firefox/firefox-service.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createFirefoxApiClient } from "./firefox-api"; -import { HOUR_MS } from "../time"; -import { createCachedDataLoader } from "../cache"; - -type AddonId = string | number; - -export interface FirefoxService { - getAddon: (addonId: AddonId) => Promise; - getAddons: ( - addonIds: Array, - ) => Promise>; - getScreenshotUrl: ( - addonId: AddonId, - screenshotIndex: number, - ) => Promise; -} - -export function createFirefoxService(): FirefoxService { - const firefox = createFirefoxApiClient(); - - const loader = createCachedDataLoader( - HOUR_MS, - (ids) => Promise.all(ids.map((id) => firefox.getAddon(id))), - ); - - const getAddon: FirefoxService["getAddon"] = (addonId) => - loader.load(addonId); - - const getAddons: FirefoxService["getAddons"] = async (addonIds) => { - const result = await loader.loadMany(addonIds); - return result.map((item) => { - if (item == null) return undefined; - if (item instanceof Error) { - console.warn("Error fetching multiple addons:", item); - return undefined; - } - return item; - }); - }; - - const getScreenshotUrl: FirefoxService["getScreenshotUrl"] = async ( - extensionId, - screenshotIndex, - ) => { - const addon = await getAddon(extensionId); - const screenshot = addon?.screenshots.find( - (screenshot) => screenshot.index == screenshotIndex, - ); - return screenshot?.rawUrl; - }; - - return { - getAddon, - getAddons, - getScreenshotUrl, - }; -} diff --git a/src/utils/urls.ts b/src/utils/urls.ts index e6c7e6c..d3b8ebd 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -1,10 +1,12 @@ +import type { ExtensionStoreName } from "../enums"; + export const SERVER_ORIGIN = process.env.SERVER_ORIGIN ?? "http://localhost:3000"; export function buildScreenshotUrl( - base: "chrome-extensions" | "firefox-addons" | "edge-addons", + storeName: ExtensionStoreName, id: string, index: number, ) { - return `${SERVER_ORIGIN}/api/rest/${base}/${id}/screenshots/${index}`; + return `${SERVER_ORIGIN}/api/rest/${storeName}/${id}/screenshots/${index}`; } diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..ab622dd --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export { version } from "../package.json";