diff --git a/cspell.yml b/cspell.yml index ccb937f..0a088d1 100644 --- a/cspell.yml +++ b/cspell.yml @@ -11,3 +11,8 @@ words: - importmap - ocfdgncpifmegplaglcnglhioflaimkd - grpahql + - libstdc + - libgcc + - adduser + - microsoftedge + - crxid diff --git a/src/assets/schema.gql b/src/assets/schema.gql index 4f0742e..7fd058e 100644 --- a/src/assets/schema.gql +++ b/src/assets/schema.gql @@ -11,6 +11,7 @@ type Query { Returns information about a list of Chrome extension ids. """ chromeExtensions(ids: [String!]!): [ChromeExtension]! + """ Returns information about a Firefox addon based on it's ID/GUID/slug. """ @@ -19,6 +20,15 @@ type Query { Returns information about a list of Firefox addon ID/GUID/slugs. """ firefoxAddons(ids: [String!]!): [FirefoxAddon]! + + """ + Returns information about an Edge addon based on it's CRX ID. + """ + edgeAddon(id: String!): EdgeAddon + """ + Returns information about a list of Edge addon IDs. + """ + edgeAddons(ids: [String!]!): [EdgeAddon]! } interface Extension { @@ -85,6 +95,29 @@ type FirefoxAddon implements Extension { dailyActiveUsers: Int! } +type EdgeAddon implements Extension { + # Extension fields + + id: String! + name: String! + iconUrl: String! + storeUrl: String! + shortDescription: String! + longDescription: String! + "Same as activeInstallCount" + users: Int! + version: String! + lastUpdated: String! + rating: Float + reviewCount: Int + screenshots: [Screenshot!]! + + # Additional fields + + "Number of users shown on `microsoftedge.microsoft.com`" + activeInstallCount: Int! +} + type Screenshot { """ The screenshot's order. diff --git a/src/dependencies.ts b/src/dependencies.ts index 7806987..f522a4f 100644 --- a/src/dependencies.ts +++ b/src/dependencies.ts @@ -1,8 +1,9 @@ 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"; -export const dependencies = createIocContainer().register({ - chrome: createChromeService, - firefox: createFirefoxService, -}); +export const dependencies = createIocContainer() + .register("chrome", createChromeService) + .register("firefox", createFirefoxService) + .register("edge", createEdgeService); diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index 2f9f975..bd0c76d 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -3,4 +3,6 @@ export const rootResolver: Gql.RootResolver = { 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), }; diff --git a/src/routes/rest-routes.ts b/src/routes/rest-routes.ts index 7a748ee..1430eca 100644 --- a/src/routes/rest-routes.ts +++ b/src/routes/rest-routes.ts @@ -9,7 +9,7 @@ export const restRoutes = createApp() .get( "/api/rest/chrome-extensions/:id/screenshots/:index", { - summary: "Redirect to Screenshot", + operationId: "chromeScreenshotRedirect", tags: ["Chrome Extensions"], description: "Redirect to a screenshot's URL from the Chrome Web Store listing", @@ -33,7 +33,7 @@ export const restRoutes = createApp() .get( "/api/rest/firefox-addons/:addonId/screenshots/:index", { - summary: "Redirect to Screenshot", + operationId: "firefoxScreenshotRedirect", tags: ["Firefox Addons"], description: "Redirect to a screenshot's URL from the Firefox Addons listing.", @@ -50,6 +50,30 @@ export const restRoutes = createApp() 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/utils/edge/edge-api.ts b/src/utils/edge/edge-api.ts new file mode 100644 index 0000000..c605a01 --- /dev/null +++ b/src/utils/edge/edge-api.ts @@ -0,0 +1,93 @@ +import { buildScreenshotUrl } from "../urls"; + +export interface EdgeApi { + getAddon(crxid: string): Promise; +} + +export function createEdgeApi(): EdgeApi { + const toGqlEdgeAddon = ( + res: GetProductDetailsByCrxId200Response, + ): Gql.EdgeAddon => ({ + id: res.crxId, + iconUrl: `https:${res.logoUrl}`, // URL without the schema (ex: "//store-images.s-microsoft.com/image/...") + lastUpdated: new Date(res.lastUpdateDate * 1000).toISOString(), + longDescription: res.description, + shortDescription: res.shortDescription, + name: res.name, + rating: res.averageRating, + reviewCount: res.ratingCount, + version: res.version, + users: res.activeInstallCount, + activeInstallCount: res.activeInstallCount, + storeUrl: `https://microsoftedge.microsoft.com/addons/detail/${res.crxId}`, + screenshots: res.screenshots.map((ss, i) => ({ + index: i, + indexUrl: buildScreenshotUrl("edge-addons", res.crxId, i), + rawUrl: `https:${ss.uri}`, // URL without the schema (ex: "//store-images.s-microsoft.com/image/...") + })), + }); + + const getAddon: EdgeApi["getAddon"] = async (crxid) => { + const res = await fetch( + `https://microsoftedge.microsoft.com/addons/getproductdetailsbycrxid/${crxid}`, + ); + if (res.status !== 200) { + throw Error("Edge API request failed", { cause: res }); + } + + const json = (await res.json()) as GetProductDetailsByCrxId200Response; + + return toGqlEdgeAddon(json); + }; + + return { + getAddon, + }; +} + +type GetProductDetailsByCrxId200Response = { + availability: string[]; + activeInstallCount: number; + storeProductId: string; + name: string; + logoUrl: string; + thumbnailUrl: string; + description: string; + developer: string; + category: string; + isInstalled: boolean; + crxId: string; + manifest: string; + isHavingMatureContent: boolean; + version: string; + lastUpdateDate: number; + privacyUrl: string; + availabilityId: string; + skuId: string; + locale: string; + market: string; + averageRating: number; + ratingCount: number; + availableLanguages: string[]; + metadata: { + publisherId: string; + }; + shortDescription: string; + searchKeywords: string; + screenshots: Array<{ + caption: string; + imagePurpose: string; + uri: string; + }>; + videos: unknown[]; + largePromotionImage: { + caption: string; + imagePurpose: string; + uri: string; + }; + publisherWebsiteUri: string; + isBadgedAsFeatured: boolean; + privacyData: { + privacyPolicyRequired: boolean; + }; +}; diff --git a/src/utils/edge/edge-service.ts b/src/utils/edge/edge-service.ts new file mode 100644 index 0000000..09ed314 --- /dev/null +++ b/src/utils/edge/edge-service.ts @@ -0,0 +1,52 @@ +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-service.ts b/src/utils/firefox/firefox-service.ts index 76b0287..e7bb68a 100644 --- a/src/utils/firefox/firefox-service.ts +++ b/src/utils/firefox/firefox-service.ts @@ -18,10 +18,10 @@ export interface FirefoxService { export function createFirefoxService(): FirefoxService { const firefox = createFirefoxApiClient(); - const loader = createCachedDataLoader< - string | number, - Gql.FirefoxAddon | undefined - >(HOUR_MS, (ids) => Promise.all(ids.map((id) => firefox.getAddon(id)))); + const loader = createCachedDataLoader( + HOUR_MS, + (ids) => Promise.all(ids.map((id) => firefox.getAddon(id))), + ); const getAddon: FirefoxService["getAddon"] = (addonId) => loader.load(addonId); diff --git a/src/utils/urls.ts b/src/utils/urls.ts index 46adcff..e6c7e6c 100644 --- a/src/utils/urls.ts +++ b/src/utils/urls.ts @@ -2,7 +2,7 @@ export const SERVER_ORIGIN = process.env.SERVER_ORIGIN ?? "http://localhost:3000"; export function buildScreenshotUrl( - base: "chrome-extensions" | "firefox-addons", + base: "chrome-extensions" | "firefox-addons" | "edge-addons", id: string, index: number, ) {