From 037d942373f090b954e94cac04b59081f2e91e1a Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 26 Feb 2026 07:02:10 +0000 Subject: [PATCH 1/2] refactor: use zod-openapi for ensanalytics-api --- .../src/handlers/ensanalytics-api.routes.ts | 74 ++++++ .../src/handlers/ensanalytics-api.test.ts | 43 ++-- apps/ensapi/src/handlers/ensanalytics-api.ts | 243 +++++++----------- apps/ensapi/src/stub-routes.ts | 3 +- 4 files changed, 185 insertions(+), 178 deletions(-) create mode 100644 apps/ensapi/src/handlers/ensanalytics-api.routes.ts diff --git a/apps/ensapi/src/handlers/ensanalytics-api.routes.ts b/apps/ensapi/src/handlers/ensanalytics-api.routes.ts new file mode 100644 index 000000000..9491ebd8b --- /dev/null +++ b/apps/ensapi/src/handlers/ensanalytics-api.routes.ts @@ -0,0 +1,74 @@ +import { createRoute } from "@hono/zod-openapi"; +import { REFERRERS_PER_LEADERBOARD_PAGE_MAX } from "@namehash/ens-referrals"; +import { z } from "zod/v4"; + +import { makeLowercaseAddressSchema } from "@ensnode/ensnode-sdk/internal"; + +export const basePath = "/ensanalytics"; + +// Pagination query parameters schema (mirrors ReferrerLeaderboardPageRequest) +const paginationQuerySchema = z.object({ + 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"), +}); + +export const getReferrerLeaderboardRoute = createRoute({ + method: "get", + path: "/referrers", + tags: ["ENSAwards"], + summary: "Get Referrer Leaderboard", + description: "Returns a paginated page from the referrer leaderboard", + request: { + query: paginationQuerySchema, + }, + responses: { + 200: { + description: "Successfully retrieved referrer leaderboard page", + }, + 500: { + description: "Internal server error", + }, + }, +}); + +export const getReferrerDetailRoute = createRoute({ + method: "get", + path: "/referrers/{referrer}", + tags: ["ENSAwards"], + summary: "Get Referrer Detail", + description: "Returns detailed information for a specific referrer by address", + request: { + params: referrerAddressSchema, + }, + responses: { + 200: { + description: "Successfully retrieved referrer detail", + }, + 500: { + description: "Internal server error", + }, + 503: { + description: "Service unavailable - referrer leaderboard data not yet cached", + }, + }, +}); + +export const routes = [getReferrerLeaderboardRoute, getReferrerDetailRoute]; diff --git a/apps/ensapi/src/handlers/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics-api.test.ts index 64229e014..0d0dcefc8 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api.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"; @@ -53,27 +52,31 @@ describe("/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; // Act: send test request to fetch 1st page - const responsePage1 = await client.referrers - .$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) - .then((r) => r.json()) - .then(deserializeReferrerLeaderboardPageResponse); + const httpResponsePage1 = await app.request( + `/referrers?recordsPerPage=${recordsPerPage}&page=1`, + ); + const responsePage1 = deserializeReferrerLeaderboardPageResponse( + await httpResponsePage1.json(), + ); // Act: send test request to fetch 2nd page - const responsePage2 = await client.referrers - .$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "2" } }, {}) - .then((r) => r.json()) - .then(deserializeReferrerLeaderboardPageResponse); + const httpResponsePage2 = await app.request( + `/referrers?recordsPerPage=${recordsPerPage}&page=2`, + ); + const responsePage2 = deserializeReferrerLeaderboardPageResponse( + await httpResponsePage2.json(), + ); // Act: send test request to fetch 3rd page - const responsePage3 = await client.referrers - .$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "3" } }, {}) - .then((r) => r.json()) - .then(deserializeReferrerLeaderboardPageResponse); + const httpResponsePage3 = await app.request( + `/referrers?recordsPerPage=${recordsPerPage}&page=3`, + ); + const responsePage3 = deserializeReferrerLeaderboardPageResponse( + await httpResponsePage3.json(), + ); // Assert: 1st page results const expectedResponsePage1 = { @@ -144,15 +147,11 @@ describe("/ensanalytics", () => { return await next(); }); - // Arrange: create the test client from the app instance - const client = testClient(app); const recordsPerPage = 10; // Act: send test request to fetch 1st page - const response = await client.referrers - .$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "1" } }, {}) - .then((r) => r.json()) - .then(deserializeReferrerLeaderboardPageResponse); + const httpResponse = await app.request(`/referrers?recordsPerPage=${recordsPerPage}&page=1`); + const response = deserializeReferrerLeaderboardPageResponse(await httpResponse.json()); // Assert: empty page results const expectedResponse = { diff --git a/apps/ensapi/src/handlers/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics-api.ts index 1912a8bbf..35d9f7e5d 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api.ts @@ -1,186 +1,119 @@ import { getReferrerDetail, getReferrerLeaderboardPage, - REFERRERS_PER_LEADERBOARD_PAGE_MAX, type ReferrerDetailResponse, ReferrerDetailResponseCodes, - type ReferrerLeaderboardPageRequest, type ReferrerLeaderboardPageResponse, ReferrerLeaderboardPageResponseCodes, serializeReferrerDetailResponse, serializeReferrerLeaderboardPageResponse, } from "@namehash/ens-referrals"; -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 { referrerLeaderboardMiddleware } from "@/middleware/referrer-leaderboard.middleware"; -const logger = makeLogger("ensanalytics-api"); - -// Pagination query parameters schema (mirrors ReferrerLeaderboardPageRequest) -const paginationQuerySchema = z.object({ - 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; +import { getReferrerDetailRoute, getReferrerLeaderboardRoute } from "./ensanalytics-api.routes"; -const app = factory - .createApp() - - // Apply referrer leaderboard cache middleware to all routes in this handler - .use(referrerLeaderboardMiddleware) +const logger = makeLogger("ensanalytics-api"); - // Get a page from the referrer leaderboard - .get( - "/referrers", - describeRoute({ - tags: ["ENSAwards"], - summary: "Get Referrer Leaderboard", - description: "Returns a paginated page from the referrer leaderboard", - responses: { - 200: { - description: "Successfully retrieved referrer leaderboard page", - }, - 500: { - description: "Internal server error", - }, - }, - }), - validate("query", paginationQuerySchema), - async (c) => { - // context must be set by the required middleware - if (c.var.referrerLeaderboard === undefined) { - throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`); - } +const app = createApp(); - try { - if (c.var.referrerLeaderboard instanceof Error) { - return c.json( - serializeReferrerLeaderboardPageResponse({ - responseCode: ReferrerLeaderboardPageResponseCodes.Error, - error: "Internal Server Error", - errorMessage: "Failed to load referrer leaderboard data.", - } satisfies ReferrerLeaderboardPageResponse), - 500, - ); - } +// Apply referrer leaderboard cache middleware to all routes in this handler +app.use(referrerLeaderboardMiddleware); - const { page, recordsPerPage } = c.req.valid("query"); - const leaderboardPage = getReferrerLeaderboardPage( - { page, recordsPerPage }, - c.var.referrerLeaderboard, - ); +// Get a page from the referrer leaderboard +app.openapi(getReferrerLeaderboardRoute, async (c) => { + // context must be set by the required middleware + if (c.var.referrerLeaderboard === undefined) { + throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`); + } - return c.json( - serializeReferrerLeaderboardPageResponse({ - responseCode: ReferrerLeaderboardPageResponseCodes.Ok, - data: leaderboardPage, - } satisfies ReferrerLeaderboardPageResponse), - ); - } catch (error) { - logger.error({ error }, "Error in /ensanalytics/referrers 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, - ); - } - }, - ); + try { + if (c.var.referrerLeaderboard instanceof Error) { + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Error, + error: "Internal Server Error", + errorMessage: "Failed to load referrer leaderboard data.", + } satisfies ReferrerLeaderboardPageResponse), + 500, + ); + } -// Referrer address parameter schema -const referrerAddressSchema = z.object({ - referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"), + const { page, recordsPerPage } = c.req.valid("query"); + const leaderboardPage = getReferrerLeaderboardPage( + { page, recordsPerPage }, + c.var.referrerLeaderboard, + ); + + return c.json( + serializeReferrerLeaderboardPageResponse({ + responseCode: ReferrerLeaderboardPageResponseCodes.Ok, + data: leaderboardPage, + } satisfies ReferrerLeaderboardPageResponse), + ); + } catch (error) { + logger.error({ error }, "Error in /ensanalytics/referrers 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 -app.get( - "/referrers/:referrer", - describeRoute({ - tags: ["ENSAwards"], - summary: "Get Referrer Detail", - description: "Returns detailed information for a specific referrer by address", - responses: { - 200: { - description: "Successfully retrieved referrer detail", - }, - 500: { - description: "Internal server error", - }, - 503: { - description: "Service unavailable - referrer leaderboard data not yet cached", - }, - }, - }), - validate("param", referrerAddressSchema), - async (c) => { - // context must be set by the required middleware - if (c.var.referrerLeaderboard === undefined) { - throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`); - } - - try { - // Check if leaderboard failed to load - if (c.var.referrerLeaderboard instanceof Error) { - return c.json( - serializeReferrerDetailResponse({ - responseCode: ReferrerDetailResponseCodes.Error, - error: "Service Unavailable", - errorMessage: "Referrer leaderboard data has not been successfully cached yet.", - } satisfies ReferrerDetailResponse), - 503, - ); - } - - const { referrer } = c.req.valid("param"); - const detail = getReferrerDetail(referrer, c.var.referrerLeaderboard); - - return c.json( - serializeReferrerDetailResponse({ - responseCode: ReferrerDetailResponseCodes.Ok, - data: detail, - } satisfies ReferrerDetailResponse), - ); - } catch (error) { - logger.error({ error }, "Error in /ensanalytics/referrers/:referrer endpoint"); - const errorMessage = - error instanceof Error - ? error.message - : "An unexpected error occurred while processing your request"; +app.openapi(getReferrerDetailRoute, async (c) => { + // context must be set by the required middleware + if (c.var.referrerLeaderboard === undefined) { + throw new Error(`Invariant(ensanalytics-api): referrerLeaderboardMiddleware required`); + } + + try { + // Check if leaderboard failed to load + if (c.var.referrerLeaderboard instanceof Error) { return c.json( serializeReferrerDetailResponse({ responseCode: ReferrerDetailResponseCodes.Error, - error: "Internal server error", - errorMessage, + error: "Service Unavailable", + errorMessage: "Referrer leaderboard data has not been successfully cached yet.", } satisfies ReferrerDetailResponse), - 500, + 503, ); } - }, -); + + const { referrer } = c.req.valid("param"); + const detail = getReferrerDetail(referrer, c.var.referrerLeaderboard); + + return c.json( + serializeReferrerDetailResponse({ + responseCode: ReferrerDetailResponseCodes.Ok, + data: detail, + } satisfies ReferrerDetailResponse), + ); + } catch (error) { + logger.error({ error }, "Error in /ensanalytics/referrers/:referrer endpoint"); + const errorMessage = + error instanceof Error + ? error.message + : "An unexpected error occurred while processing your request"; + return c.json( + serializeReferrerDetailResponse({ + responseCode: ReferrerDetailResponseCodes.Error, + error: "Internal server error", + errorMessage, + } satisfies ReferrerDetailResponse), + 500, + ); + } +}); export default app; diff --git a/apps/ensapi/src/stub-routes.ts b/apps/ensapi/src/stub-routes.ts index 73a0372a9..1a79c4b57 100644 --- a/apps/ensapi/src/stub-routes.ts +++ b/apps/ensapi/src/stub-routes.ts @@ -1,6 +1,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 ensnodeRoutes from "./handlers/ensnode-api.routes"; import * as resolutionRoutes from "./handlers/resolution-api.routes"; @@ -12,7 +13,7 @@ import * as resolutionRoutes from "./handlers/resolution-api.routes"; export function createStubRoutesForSpec() { const app = new OpenAPIHono(); - const routeGroups = [amIRealtimeRoutes, ensnodeRoutes, resolutionRoutes]; + const routeGroups = [amIRealtimeRoutes, ensanalyticsRoutes, ensnodeRoutes, resolutionRoutes]; for (const group of routeGroups) { for (const route of group.routes) { From 1e1f7b16c37f5f5bae94af66d332f2f6eca3e8ce Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 26 Feb 2026 08:08:56 +0000 Subject: [PATCH 2/2] lint file --- apps/ensapi/src/stub-routes.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/ensapi/src/stub-routes.ts b/apps/ensapi/src/stub-routes.ts index 3cdc1595a..14a2c8a9a 100644 --- a/apps/ensapi/src/stub-routes.ts +++ b/apps/ensapi/src/stub-routes.ts @@ -14,7 +14,13 @@ import * as resolutionRoutes from "./handlers/resolution-api.routes"; export function createStubRoutesForSpec() { const app = new OpenAPIHono(); - const routeGroups = [amIRealtimeRoutes, ensnodeRoutes, ensanalyticsRoutes, nameTokensRoutes, resolutionRoutes]; + const routeGroups = [ + amIRealtimeRoutes, + ensnodeRoutes, + ensanalyticsRoutes, + nameTokensRoutes, + resolutionRoutes, + ]; for (const group of routeGroups) { for (const route of group.routes) {