Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions cspell.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ words:
- importmap
- ocfdgncpifmegplaglcnglhioflaimkd
- grpahql
- libstdc
- libgcc
- adduser
- microsoftedge
- crxid
33 changes: 33 additions & 0 deletions src/assets/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions src/dependencies.ts
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 2 additions & 0 deletions src/graphql/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
};
28 changes: 26 additions & 2 deletions src/routes/rest-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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.",
Expand All @@ -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;
},
Expand Down
93 changes: 93 additions & 0 deletions src/utils/edge/edge-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { buildScreenshotUrl } from "../urls";

export interface EdgeApi {
getAddon(crxid: string): Promise<Gql.EdgeAddon>;
}

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;
};
};
52 changes: 52 additions & 0 deletions src/utils/edge/edge-service.ts
Original file line number Diff line number Diff line change
@@ -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<Gql.EdgeAddon | undefined>;
getAddons: (ids: string[]) => Promise<Array<Gql.EdgeAddon | undefined>>;
getScreenshotUrl: (
addonId: string,
screenshotIndex: number,
) => Promise<string | undefined>;
}

export function createEdgeService(): EdgeService {
const api = createEdgeApi();

const loader = createCachedDataLoader<string, Gql.EdgeAddon | undefined>(
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,
};
}
8 changes: 4 additions & 4 deletions src/utils/firefox/firefox-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AddonId, Gql.FirefoxAddon | undefined>(
HOUR_MS,
(ids) => Promise.all(ids.map((id) => firefox.getAddon(id))),
);

const getAddon: FirefoxService["getAddon"] = (addonId) =>
loader.load(addonId);
Expand Down
2 changes: 1 addition & 1 deletion src/utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down
Loading