diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts new file mode 100644 index 000000000..2dfd5634c --- /dev/null +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts @@ -0,0 +1,127 @@ +import { createRoute } from "@hono/zod-openapi"; +import { + MAX_EDITIONS_PER_REQUEST, + REFERRERS_PER_LEADERBOARD_PAGE_MAX, +} from "@namehash/ens-referrals/v1"; +import { + makeReferralProgramEditionSlugSchema, + makeReferrerMetricsEditionsArraySchema, +} from "@namehash/ens-referrals/v1/internal"; +import { z } from "zod/v4"; + +import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal"; + +export const basePath = "/v1/ensanalytics"; + +/** + * Query parameters schema for referrer leaderboard page requests. + * Validates edition slug, page number, and records per page. + */ +const referrerLeaderboardPageQuerySchema = z.object({ + edition: makeReferralProgramEditionSlugSchema("edition"), + page: z + .optional(z.coerce.number().int().min(1, "Page must be a positive integer")) + .describe("Page number for pagination"), + recordsPerPage: z + .optional( + z.coerce + .number() + .int() + .min(1, "Records per page must be at least 1") + .max( + REFERRERS_PER_LEADERBOARD_PAGE_MAX, + `Records per page must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`, + ), + ) + .describe("Number of referrers per page"), +}); + +// Referrer address parameter schema +const referrerAddressSchema = z.object({ + referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"), +}); + +// Editions query parameter schema +const editionsQuerySchema = z.object({ + editions: z + .string() + .describe("Comma-separated list of edition slugs") + .transform((value) => value.split(",").map((s) => s.trim())) + .pipe(makeReferrerMetricsEditionsArraySchema("editions")), +}); + +export const getReferralLeaderboardRoute = createRoute({ + method: "get", + path: "/referral-leaderboard", + tags: ["ENSAwards"], + summary: "Get Referrer Leaderboard (v1)", + description: "Returns a paginated page from the referrer leaderboard for a specific edition", + request: { + query: referrerLeaderboardPageQuerySchema, + }, + responses: { + 200: { + description: "Successfully retrieved referrer leaderboard page", + }, + 404: { + description: "Unknown edition slug", + }, + 500: { + description: "Internal server error", + }, + 503: { + description: "Service unavailable", + }, + }, +}); + +export const getReferrerDetailRoute = createRoute({ + method: "get", + path: "/referrer/{referrer}", + tags: ["ENSAwards"], + summary: "Get Referrer Detail for Editions (v1)", + description: `Returns detailed information for a specific referrer for the requested editions. Requires 1-${MAX_EDITIONS_PER_REQUEST} distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.`, + request: { + params: referrerAddressSchema, + query: editionsQuerySchema, + }, + responses: { + 200: { + description: "Successfully retrieved referrer detail for requested editions", + }, + 400: { + description: "Invalid request", + }, + 404: { + description: "Unknown edition slug", + }, + 500: { + description: "Internal server error", + }, + 503: { + description: "Service unavailable", + }, + }, +}); + +export const getEditionsRoute = createRoute({ + method: "get", + path: "/editions", + tags: ["ENSAwards"], + summary: "Get Edition Config Set (v1)", + description: + "Returns the currently configured referral program edition config set. Editions are sorted in descending order by start timestamp (most recent first).", + responses: { + 200: { + description: "Successfully retrieved edition config set", + }, + 500: { + description: "Internal server error", + }, + 503: { + description: "Service unavailable", + }, + }, +}); + +export const routes = [getReferralLeaderboardRoute, getReferrerDetailRoute, getEditionsRoute]; diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index 2bbe12ef0..da08c2db7 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -1,5 +1,4 @@ -import { testClient } from "hono/testing"; -import { describe, expect, it, vi } from "vitest"; // Or your preferred test runner +import { describe, expect, it, vi } from "vitest"; import { ENSNamespaceIds } from "@ensnode/datasources"; @@ -91,28 +90,32 @@ describe("/v1/ensanalytics", () => { const allPossibleReferrers = referrerLeaderboardPageResponseOk.data.referrers; const allPossibleReferrersIterator = allPossibleReferrers[Symbol.iterator](); - // Arrange: create the test client from the app instance - const client = testClient(app); const recordsPerPage = 10; const edition = "2025-12"; // Act: send test request to fetch 1st page - const responsePage1 = await client["referral-leaderboard"] - .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) - .then((r) => r.json()) - .then(deserializeReferrerLeaderboardPageResponse); + const httpResponsePage1 = await app.request( + `/referral-leaderboard?edition=${edition}&recordsPerPage=${recordsPerPage}&page=1`, + ); + const responsePage1 = deserializeReferrerLeaderboardPageResponse( + await httpResponsePage1.json(), + ); // Act: send test request to fetch 2nd page - const responsePage2 = await client["referral-leaderboard"] - .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "2" } }, {}) - .then((r) => r.json()) - .then(deserializeReferrerLeaderboardPageResponse); + const httpResponsePage2 = await app.request( + `/referral-leaderboard?edition=${edition}&recordsPerPage=${recordsPerPage}&page=2`, + ); + const responsePage2 = deserializeReferrerLeaderboardPageResponse( + await httpResponsePage2.json(), + ); // Act: send test request to fetch 3rd page - const responsePage3 = await client["referral-leaderboard"] - .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "3" } }, {}) - .then((r) => r.json()) - .then(deserializeReferrerLeaderboardPageResponse); + const httpResponsePage3 = await app.request( + `/referral-leaderboard?edition=${edition}&recordsPerPage=${recordsPerPage}&page=3`, + ); + const responsePage3 = deserializeReferrerLeaderboardPageResponse( + await httpResponsePage3.json(), + ); // Assert: 1st page results const expectedResponsePage1 = { @@ -211,16 +214,14 @@ describe("/v1/ensanalytics", () => { return await next(); }); - // Arrange: create the test client from the app instance - const client = testClient(app); const recordsPerPage = 10; const edition = "2025-12"; // Act: send test request to fetch 1st page - const response = await client["referral-leaderboard"] - .$get({ query: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) - .then((r) => r.json()) - .then(deserializeReferrerLeaderboardPageResponse); + const httpResponse = await app.request( + `/referral-leaderboard?edition=${edition}&recordsPerPage=${recordsPerPage}&page=1`, + ); + const response = deserializeReferrerLeaderboardPageResponse(await httpResponse.json()); // Assert: empty page results const expectedResponse = { @@ -282,15 +283,12 @@ describe("/v1/ensanalytics", () => { return await next(); }); - // Arrange: create the test client from the app instance - const client = testClient(app); const recordsPerPage = 10; const invalidEdition = "invalid-edition"; // Act: send test request with invalid edition slug - const httpResponse = await client["referral-leaderboard"].$get( - { query: { edition: invalidEdition, recordsPerPage: `${recordsPerPage}`, page: "1" } }, - {}, + const httpResponse = await app.request( + `/referral-leaderboard?edition=${invalidEdition}&recordsPerPage=${recordsPerPage}&page=1`, ); const responseData = await httpResponse.json(); const response = deserializeReferrerLeaderboardPageResponse(responseData); diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts index 5806befb0..320a2e68e 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts @@ -1,13 +1,10 @@ import { getReferrerEditionMetrics, getReferrerLeaderboardPage, - MAX_EDITIONS_PER_REQUEST, - REFERRERS_PER_LEADERBOARD_PAGE_MAX, type ReferralProgramEditionConfigSetResponse, ReferralProgramEditionConfigSetResponseCodes, type ReferralProgramEditionSlug, type ReferrerLeaderboard, - type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, type ReferrerMetricsEditionsData, @@ -17,396 +14,281 @@ import { serializeReferrerLeaderboardPageResponse, serializeReferrerMetricsEditionsResponse, } from "@namehash/ens-referrals/v1"; -import { - makeReferralProgramEditionSlugSchema, - makeReferrerMetricsEditionsArraySchema, -} from "@namehash/ens-referrals/v1/internal"; -import { describeRoute } from "hono-openapi"; -import { z } from "zod/v4"; - -import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal"; -import { validate } from "@/lib/handlers/validate"; -import { factory } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { referralLeaderboardEditionsCachesMiddleware } from "@/middleware/referral-leaderboard-editions-caches.middleware"; import { referralProgramEditionConfigSetMiddleware } from "@/middleware/referral-program-edition-set.middleware"; -const logger = makeLogger("ensanalytics-api-v1"); +import { + getEditionsRoute, + getReferralLeaderboardRoute, + getReferrerDetailRoute, +} from "./ensanalytics-api-v1.routes"; -/** - * Query parameters schema for referrer leaderboard page requests. - * Validates edition slug, page number, and records per page. - */ -const referrerLeaderboardPageQuerySchema = z.object({ - edition: makeReferralProgramEditionSlugSchema("edition"), - page: z - .optional(z.coerce.number().int().min(1, "Page must be a positive integer")) - .describe("Page number for pagination"), - recordsPerPage: z - .optional( - z.coerce - .number() - .int() - .min(1, "Records per page must be at least 1") - .max( - REFERRERS_PER_LEADERBOARD_PAGE_MAX, - `Records per page must not exceed ${REFERRERS_PER_LEADERBOARD_PAGE_MAX}`, - ), - ) - .describe("Number of referrers per page"), -}) satisfies z.ZodType; - -const app = factory - .createApp() - - // Apply referral program edition config set middleware - .use(referralProgramEditionConfigSetMiddleware) - - // Apply referrer leaderboard cache middleware (depends on edition config set middleware) - .use(referralLeaderboardEditionsCachesMiddleware) - - // Get a page from the referrer leaderboard for a specific edition - .get( - "/referral-leaderboard", - describeRoute({ - tags: ["ENSAwards"], - summary: "Get Referrer Leaderboard (v1)", - description: "Returns a paginated page from the referrer leaderboard for a specific edition", - responses: { - 200: { - description: "Successfully retrieved referrer leaderboard page", - }, - 404: { - description: "Unknown edition slug", - }, - 500: { - description: "Internal server error", - }, - 503: { - description: "Service unavailable", - }, - }, - }), - validate("query", referrerLeaderboardPageQuerySchema), - async (c) => { - // context must be set by the required middleware - if (c.var.referralLeaderboardEditionsCaches === undefined) { - throw new Error( - `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, - ); - } - - try { - const { edition, page, recordsPerPage } = c.req.valid("query"); - - // Check if edition set failed to load - if (c.var.referralLeaderboardEditionsCaches instanceof Error) { - logger.error( - { error: c.var.referralLeaderboardEditionsCaches }, - "Referral program edition set failed to load", - ); - return c.json( - serializeReferrerLeaderboardPageResponse({ - responseCode: ReferrerLeaderboardPageResponseCodes.Error, - error: "Service Unavailable", - errorMessage: "Referral program configuration is currently unavailable.", - } satisfies ReferrerLeaderboardPageResponse), - 503, - ); - } +const logger = makeLogger("ensanalytics-api-v1"); - // Get the specific edition's cache - const editionCache = c.var.referralLeaderboardEditionsCaches.get(edition); +const app = createApp(); + +// Apply referral program edition config set middleware +app.use(referralProgramEditionConfigSetMiddleware); + +// Apply referrer leaderboard cache middleware (depends on edition config set middleware) +app.use(referralLeaderboardEditionsCachesMiddleware); + +// Get a page from the referrer leaderboard for a specific edition +app.openapi(getReferralLeaderboardRoute, async (c) => { + // context must be set by the required middleware + if (c.var.referralLeaderboardEditionsCaches === undefined) { + throw new Error( + `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, + ); + } + + try { + const { edition, page, recordsPerPage } = c.req.valid("query"); + + // Check if edition set failed to load + if (c.var.referralLeaderboardEditionsCaches instanceof Error) { + logger.error( + { error: c.var.referralLeaderboardEditionsCaches }, + "Referral program edition set failed to load", + ); + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Error, + error: "Service Unavailable", + errorMessage: "Referral program configuration is currently unavailable.", + } satisfies ReferrerLeaderboardPageResponse), + 503, + ); + } + + // Get the specific edition's cache + const editionCache = c.var.referralLeaderboardEditionsCaches.get(edition); + + if (!editionCache) { + const configuredEditions = Array.from(c.var.referralLeaderboardEditionsCaches.keys()); + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Error, + error: "Not Found", + errorMessage: `Unknown edition: ${edition}. Valid editions: ${configuredEditions.join(", ")}`, + } satisfies ReferrerLeaderboardPageResponse), + 404, + ); + } + + // Read from the edition's cache + const leaderboard = await editionCache.read(); + + // Check if this specific edition failed to build + if (leaderboard instanceof Error) { + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Error, + error: "Service Unavailable", + errorMessage: `Failed to load leaderboard for edition ${edition}.`, + } satisfies ReferrerLeaderboardPageResponse), + 503, + ); + } + + const leaderboardPage = getReferrerLeaderboardPage({ page, recordsPerPage }, leaderboard); + + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Ok, + data: leaderboardPage, + } satisfies ReferrerLeaderboardPageResponse), + ); + } catch (error) { + logger.error({ error }, "Error in /v1/ensanalytics/referral-leaderboard endpoint"); + const errorMessage = + error instanceof Error + ? error.message + : "An unexpected error occurred while processing your request"; + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Error, + error: "Internal server error", + errorMessage, + } satisfies ReferrerLeaderboardPageResponse), + 500, + ); + } +}); +// Get referrer detail for a specific address for requested editions +app.openapi(getReferrerDetailRoute, async (c) => { + // context must be set by the required middleware + if (c.var.referralLeaderboardEditionsCaches === undefined) { + throw new Error( + `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, + ); + } + + try { + const { referrer } = c.req.valid("param"); + const { editions } = c.req.valid("query"); + + // Check if edition set failed to load + if (c.var.referralLeaderboardEditionsCaches instanceof Error) { + logger.error( + { error: c.var.referralLeaderboardEditionsCaches }, + "Referral program edition set failed to load", + ); + return c.json( + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, + error: "Service Unavailable", + errorMessage: "Referral program configuration is currently unavailable.", + } satisfies ReferrerMetricsEditionsResponse), + 503, + ); + } + + // Type narrowing: at this point we know it's not an Error + const editionsCaches = c.var.referralLeaderboardEditionsCaches; + + // Validate that all requested editions are recognized (exist in the cache map) + const configuredEditions = Array.from(editionsCaches.keys()); + const unrecognizedEditions = editions.filter((edition) => !editionsCaches.has(edition)); + + if (unrecognizedEditions.length > 0) { + return c.json( + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, + error: "Not Found", + errorMessage: `Unknown edition(s): ${unrecognizedEditions.join(", ")}. Valid editions: ${configuredEditions.join(", ")}`, + } satisfies ReferrerMetricsEditionsResponse), + 404, + ); + } + + // Read all requested edition caches + const editionLeaderboards = await Promise.all( + editions.map(async (editionSlug) => { + const editionCache = editionsCaches.get(editionSlug); if (!editionCache) { - const configuredEditions = Array.from(c.var.referralLeaderboardEditionsCaches.keys()); - return c.json( - serializeReferrerLeaderboardPageResponse({ - responseCode: ReferrerLeaderboardPageResponseCodes.Error, - error: "Not Found", - errorMessage: `Unknown edition: ${edition}. Valid editions: ${configuredEditions.join(", ")}`, - } satisfies ReferrerLeaderboardPageResponse), - 404, - ); + throw new Error(`Invariant: edition cache for ${editionSlug} should exist`); } - - // Read from the edition's cache const leaderboard = await editionCache.read(); - - // Check if this specific edition failed to build - if (leaderboard instanceof Error) { - return c.json( - serializeReferrerLeaderboardPageResponse({ - responseCode: ReferrerLeaderboardPageResponseCodes.Error, - error: "Service Unavailable", - errorMessage: `Failed to load leaderboard for edition ${edition}.`, - } satisfies ReferrerLeaderboardPageResponse), - 503, - ); - } - - const leaderboardPage = getReferrerLeaderboardPage({ page, recordsPerPage }, leaderboard); - - return c.json( - serializeReferrerLeaderboardPageResponse({ - responseCode: ReferrerLeaderboardPageResponseCodes.Ok, - data: leaderboardPage, - } satisfies ReferrerLeaderboardPageResponse), - ); - } catch (error) { - logger.error({ error }, "Error in /v1/ensanalytics/referral-leaderboard endpoint"); - const errorMessage = - error instanceof Error - ? error.message - : "An unexpected error occurred while processing your request"; - return c.json( - serializeReferrerLeaderboardPageResponse({ - responseCode: ReferrerLeaderboardPageResponseCodes.Error, - error: "Internal server error", - errorMessage, - } satisfies ReferrerLeaderboardPageResponse), - 500, - ); - } - }, - ); - -// Referrer address parameter schema -const referrerAddressSchema = z.object({ - referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"), -}); - -// Editions query parameter schema -const editionsQuerySchema = z.object({ - editions: z - .string() - .describe("Comma-separated list of edition slugs") - .transform((value) => value.split(",").map((s) => s.trim())) - .pipe(makeReferrerMetricsEditionsArraySchema("editions")), + return { editionSlug, leaderboard }; + }), + ); + + // Validate that all requested editions have cached data (no errors) + const uncachedEditions = editionLeaderboards + .filter(({ leaderboard }) => leaderboard instanceof Error) + .map(({ editionSlug }) => editionSlug); + + if (uncachedEditions.length > 0) { + return c.json( + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, + error: "Service Unavailable", + errorMessage: `Referrer leaderboard data not cached for edition(s): ${uncachedEditions.join(", ")}`, + } satisfies ReferrerMetricsEditionsResponse), + 503, + ); + } + + // Type narrowing: at this point all leaderboards are guaranteed to be non-Error + const validEditionLeaderboards = editionLeaderboards.filter( + ( + item, + ): item is { + editionSlug: ReferralProgramEditionSlug; + leaderboard: ReferrerLeaderboard; + } => !(item.leaderboard instanceof Error), + ); + + // Build response data for the requested editions + const editionsData = Object.fromEntries( + validEditionLeaderboards.map(({ editionSlug, leaderboard }) => [ + editionSlug, + getReferrerEditionMetrics(referrer, leaderboard), + ]), + ) as ReferrerMetricsEditionsData; + + return c.json( + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Ok, + data: editionsData, + } satisfies ReferrerMetricsEditionsResponse), + ); + } catch (error) { + logger.error({ error }, "Error in /v1/ensanalytics/referrer/:referrer endpoint"); + const errorMessage = + error instanceof Error + ? error.message + : "An unexpected error occurred while processing your request"; + return c.json( + serializeReferrerMetricsEditionsResponse({ + responseCode: ReferrerMetricsEditionsResponseCodes.Error, + error: "Internal server error", + errorMessage, + } satisfies ReferrerMetricsEditionsResponse), + 500, + ); + } }); -// Get referrer detail for a specific address for requested editions -app - .get( - "/referrer/:referrer", - describeRoute({ - tags: ["ENSAwards"], - summary: "Get Referrer Detail for Editions (v1)", - description: `Returns detailed information for a specific referrer for the requested editions. Requires 1-${MAX_EDITIONS_PER_REQUEST} distinct edition slugs. All requested editions must be recognized and have cached data, or the request fails.`, - responses: { - 200: { - description: "Successfully retrieved referrer detail for requested editions", - }, - 400: { - description: "Invalid request", - }, - 404: { - description: "Unknown edition slug", - }, - 500: { - description: "Internal server error", - }, - 503: { - description: "Service unavailable", +// Get configured edition config set +app.openapi(getEditionsRoute, async (c) => { + // context must be set by the required middleware + if (c.var.referralProgramEditionConfigSet === undefined) { + throw new Error( + `Invariant(ensanalytics-api-v1): referralProgramEditionConfigSetMiddleware required`, + ); + } + + try { + // Check if edition config set failed to load + if (c.var.referralProgramEditionConfigSet instanceof Error) { + logger.error( + { error: c.var.referralProgramEditionConfigSet }, + "Referral program edition config set failed to load", + ); + return c.json( + serializeReferralProgramEditionConfigSetResponse({ + responseCode: ReferralProgramEditionConfigSetResponseCodes.Error, + error: "Service Unavailable", + errorMessage: "Referral program configuration is currently unavailable.", + } satisfies ReferralProgramEditionConfigSetResponse), + 503, + ); + } + + // Convert Map to array and sort by start timestamp descending + const editions = Array.from(c.var.referralProgramEditionConfigSet.values()).sort( + (a, b) => b.rules.startTime - a.rules.startTime, + ); + + return c.json( + serializeReferralProgramEditionConfigSetResponse({ + responseCode: ReferralProgramEditionConfigSetResponseCodes.Ok, + data: { + editions, }, - }, - }), - validate("param", referrerAddressSchema), - validate("query", editionsQuerySchema), - async (c) => { - // context must be set by the required middleware - if (c.var.referralLeaderboardEditionsCaches === undefined) { - throw new Error( - `Invariant(ensanalytics-api-v1): referralLeaderboardEditionsCachesMiddleware required`, - ); - } - - try { - const { referrer } = c.req.valid("param"); - const { editions } = c.req.valid("query"); - - // Check if edition set failed to load - if (c.var.referralLeaderboardEditionsCaches instanceof Error) { - logger.error( - { error: c.var.referralLeaderboardEditionsCaches }, - "Referral program edition set failed to load", - ); - return c.json( - serializeReferrerMetricsEditionsResponse({ - responseCode: ReferrerMetricsEditionsResponseCodes.Error, - error: "Service Unavailable", - errorMessage: "Referral program configuration is currently unavailable.", - } satisfies ReferrerMetricsEditionsResponse), - 503, - ); - } - - // Type narrowing: at this point we know it's not an Error - const editionsCaches = c.var.referralLeaderboardEditionsCaches; - - // Validate that all requested editions are recognized (exist in the cache map) - const configuredEditions = Array.from(editionsCaches.keys()); - const unrecognizedEditions = editions.filter((edition) => !editionsCaches.has(edition)); - - if (unrecognizedEditions.length > 0) { - return c.json( - serializeReferrerMetricsEditionsResponse({ - responseCode: ReferrerMetricsEditionsResponseCodes.Error, - error: "Not Found", - errorMessage: `Unknown edition(s): ${unrecognizedEditions.join(", ")}. Valid editions: ${configuredEditions.join(", ")}`, - } satisfies ReferrerMetricsEditionsResponse), - 404, - ); - } - - // Read all requested edition caches - const editionLeaderboards = await Promise.all( - editions.map(async (editionSlug) => { - const editionCache = editionsCaches.get(editionSlug); - if (!editionCache) { - throw new Error(`Invariant: edition cache for ${editionSlug} should exist`); - } - const leaderboard = await editionCache.read(); - return { editionSlug, leaderboard }; - }), - ); - - // Validate that all requested editions have cached data (no errors) - const uncachedEditions = editionLeaderboards - .filter(({ leaderboard }) => leaderboard instanceof Error) - .map(({ editionSlug }) => editionSlug); - - if (uncachedEditions.length > 0) { - return c.json( - serializeReferrerMetricsEditionsResponse({ - responseCode: ReferrerMetricsEditionsResponseCodes.Error, - error: "Service Unavailable", - errorMessage: `Referrer leaderboard data not cached for edition(s): ${uncachedEditions.join(", ")}`, - } satisfies ReferrerMetricsEditionsResponse), - 503, - ); - } - - // Type narrowing: at this point all leaderboards are guaranteed to be non-Error - const validEditionLeaderboards = editionLeaderboards.filter( - ( - item, - ): item is { - editionSlug: ReferralProgramEditionSlug; - leaderboard: ReferrerLeaderboard; - } => !(item.leaderboard instanceof Error), - ); - - // Build response data for the requested editions - const editionsData = Object.fromEntries( - validEditionLeaderboards.map(({ editionSlug, leaderboard }) => [ - editionSlug, - getReferrerEditionMetrics(referrer, leaderboard), - ]), - ) as ReferrerMetricsEditionsData; - - return c.json( - serializeReferrerMetricsEditionsResponse({ - responseCode: ReferrerMetricsEditionsResponseCodes.Ok, - data: editionsData, - } satisfies ReferrerMetricsEditionsResponse), - ); - } catch (error) { - logger.error( - { error }, - "Error in /v1/ensanalytics/referral-leaderboard/:referrer endpoint", - ); - const errorMessage = - error instanceof Error - ? error.message - : "An unexpected error occurred while processing your request"; - return c.json( - serializeReferrerMetricsEditionsResponse({ - responseCode: ReferrerMetricsEditionsResponseCodes.Error, - error: "Internal server error", - errorMessage, - } satisfies ReferrerMetricsEditionsResponse), - 500, - ); - } - }, - ) - - // Get configured edition config set - .get( - "/editions", - describeRoute({ - tags: ["ENSAwards"], - summary: "Get Edition Config Set (v1)", - description: - "Returns the currently configured referral program edition config set. Editions are sorted in descending order by start timestamp (most recent first).", - responses: { - 200: { - description: "Successfully retrieved edition config set", - }, - 500: { - description: "Internal server error", - }, - 503: { - description: "Service unavailable", - }, - }, - }), - async (c) => { - // context must be set by the required middleware - if (c.var.referralProgramEditionConfigSet === undefined) { - throw new Error( - `Invariant(ensanalytics-api-v1): referralProgramEditionConfigSetMiddleware required`, - ); - } - - try { - // Check if edition config set failed to load - if (c.var.referralProgramEditionConfigSet instanceof Error) { - logger.error( - { error: c.var.referralProgramEditionConfigSet }, - "Referral program edition config set failed to load", - ); - return c.json( - serializeReferralProgramEditionConfigSetResponse({ - responseCode: ReferralProgramEditionConfigSetResponseCodes.Error, - error: "Service Unavailable", - errorMessage: "Referral program configuration is currently unavailable.", - } satisfies ReferralProgramEditionConfigSetResponse), - 503, - ); - } - - // Convert Map to array and sort by start timestamp descending - const editions = Array.from(c.var.referralProgramEditionConfigSet.values()).sort( - (a, b) => b.rules.startTime - a.rules.startTime, - ); - - return c.json( - serializeReferralProgramEditionConfigSetResponse({ - responseCode: ReferralProgramEditionConfigSetResponseCodes.Ok, - data: { - editions, - }, - } satisfies ReferralProgramEditionConfigSetResponse), - ); - } catch (error) { - logger.error({ error }, "Error in /v1/ensanalytics/editions endpoint"); - const errorMessage = - error instanceof Error - ? error.message - : "An unexpected error occurred while processing your request"; - return c.json( - serializeReferralProgramEditionConfigSetResponse({ - responseCode: ReferralProgramEditionConfigSetResponseCodes.Error, - error: "Internal server error", - errorMessage, - } satisfies ReferralProgramEditionConfigSetResponse), - 500, - ); - } - }, - ); + } satisfies ReferralProgramEditionConfigSetResponse), + ); + } catch (error) { + logger.error({ error }, "Error in /v1/ensanalytics/editions endpoint"); + const errorMessage = + error instanceof Error + ? error.message + : "An unexpected error occurred while processing your request"; + return c.json( + serializeReferralProgramEditionConfigSetResponse({ + responseCode: ReferralProgramEditionConfigSetResponseCodes.Error, + error: "Internal server error", + errorMessage, + } satisfies ReferralProgramEditionConfigSetResponse), + 500, + ); + } +}); export default app; diff --git a/apps/ensapi/src/stub-routes.ts b/apps/ensapi/src/stub-routes.ts index 14a2c8a9a..043e8ae3e 100644 --- a/apps/ensapi/src/stub-routes.ts +++ b/apps/ensapi/src/stub-routes.ts @@ -2,6 +2,7 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import * as amIRealtimeRoutes from "./handlers/amirealtime-api.routes"; import * as ensanalyticsRoutes from "./handlers/ensanalytics-api.routes"; +import * as ensanalyticsV1Routes from "./handlers/ensanalytics-api-v1.routes"; import * as ensnodeRoutes from "./handlers/ensnode-api.routes"; import * as nameTokensRoutes from "./handlers/name-tokens-api.routes"; import * as resolutionRoutes from "./handlers/resolution-api.routes"; @@ -17,6 +18,7 @@ export function createStubRoutesForSpec() { const routeGroups = [ amIRealtimeRoutes, ensnodeRoutes, + ensanalyticsV1Routes, ensanalyticsRoutes, nameTokensRoutes, resolutionRoutes,