diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index a7c3ef8f3..391674616 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -17,7 +17,8 @@ "test": "vitest", "lint": "biome check --write .", "lint:ci": "biome ci", - "typecheck": "tsgo --noEmit" + "typecheck": "tsgo --noEmit", + "generate:openapi": "tsx scripts/generate-openapi.ts" }, "dependencies": { "@ensdomains/ensjs": "^4.0.2", @@ -27,7 +28,7 @@ "@ensnode/ponder-subgraph": "workspace:*", "@hono/node-server": "^1.19.5", "@hono/otel": "^0.2.2", - "@hono/standard-validator": "^0.2.2", + "@hono/zod-openapi": "1.2.1", "@namehash/ens-referrals": "workspace:*", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.0.1", @@ -51,7 +52,6 @@ "graphql": "^16.11.0", "graphql-yoga": "^5.16.0", "hono": "catalog:", - "hono-openapi": "^1.1.2", "p-memoize": "^8.0.0", "p-retry": "^7.1.0", "pg-connection-string": "catalog:", diff --git a/apps/ensapi/scripts/generate-openapi.ts b/apps/ensapi/scripts/generate-openapi.ts new file mode 100644 index 000000000..d28871c00 --- /dev/null +++ b/apps/ensapi/scripts/generate-openapi.ts @@ -0,0 +1,37 @@ +/** + * Generates the OpenAPI specification JSON for ENSApi. + * + * This script can run without any environment variables or config because it + * imports only from the lightweight `.routes.ts` description files via + * `createRoutesForSpec()`, which have zero config dependencies. + * + * The version is resolved from (in order of precedence): + * 1. OPENAPI_VERSION_OVERRIDE env var + * 2. package.json version + * 3. openapiDocumentation.info.version (placeholder fallback) + * + * Usage: + * pnpm generate:openapi > openapi.json + * pnpm generate:openapi # prints to stdout + * OPENAPI_VERSION_OVERRIDE=1.2.3 pnpm generate:openapi + */ + +import packageJson from "@/../package.json" with { type: "json" }; + +import { openapiDocumentation } from "@/openapi"; +import { createRoutesForSpec } from "@/openapi-routes"; + +const version = + process.env.OPENAPI_VERSION_OVERRIDE || packageJson.version || openapiDocumentation.info.version; + +const app = createRoutesForSpec(); +const spec = app.getOpenAPI31Document({ + ...openapiDocumentation, + info: { + ...openapiDocumentation.info, + version, + }, +}); + +process.stdout.write(JSON.stringify(spec, null, 2)); +process.stdout.write("\n"); diff --git a/apps/ensapi/src/app.ts b/apps/ensapi/src/app.ts new file mode 100644 index 000000000..ff05146ce --- /dev/null +++ b/apps/ensapi/src/app.ts @@ -0,0 +1,106 @@ +/** + * Application setup: creates the Hono app with all middleware and routes. + * + * Separated from index.ts (server startup/shutdown) so the app configuration + * is distinct from the runtime lifecycle concerns. + */ + +import packageJson from "@/../package.json" with { type: "json" }; +import config from "@/config"; + +import { otel } from "@hono/otel"; +import { cors } from "hono/cors"; +import { html } from "hono/html"; + +import { errorResponse } from "@/lib/handlers/error-response"; +import { createApp } from "@/lib/hono-factory"; +import logger from "@/lib/logger"; +import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; +import { openapiDocumentation } from "@/openapi"; + +import amIRealtimeApi from "./handlers/amirealtime-api"; +import { basePath as amIRealtimeBasePath } from "./handlers/amirealtime-api.routes"; +import ensanalyticsApi from "./handlers/ensanalytics-api"; +import { basePath as ensanalyticsBasePath } from "./handlers/ensanalytics-api.routes"; +import ensanalyticsApiV1 from "./handlers/ensanalytics-api-v1"; +import { basePath as ensanalyticsV1BasePath } from "./handlers/ensanalytics-api-v1.routes"; +import ensNodeApi from "./handlers/ensnode-api"; +import { basePath as ensnodeBasePath } from "./handlers/ensnode-api.routes"; +import subgraphApi from "./handlers/subgraph-api"; + +const app = createApp(); + +// set the X-ENSNode-Version header to the current version +app.use(async (ctx, next) => { + ctx.header("x-ensnode-version", packageJson.version); + return next(); +}); + +// use CORS middleware +app.use(cors({ origin: "*" })); + +// include automatic OpenTelemetry instrumentation for incoming requests +app.use(otel()); + +// add ENSIndexer Indexing Status Middleware to all routes for convenience +app.use(indexingStatusMiddleware); + +// host welcome page +app.get("/", (c) => + c.html(html` + + + + + + ENSApi + + +

Hello, World!

+

You've reached the root of an ENSApi instance. You might be looking for the ENSNode documentation.

+ + +`), +); + +// use ENSNode HTTP API at /api +app.route(ensnodeBasePath, ensNodeApi); + +// Subgraph API is a GraphQL middleware handler, not an OpenAPI route, +// so it has no .routes.ts file or basePath export. +app.route("/subgraph", subgraphApi); + +// use ENSAnalytics API at /ensanalytics (v0, implicit) +app.route(ensanalyticsBasePath, ensanalyticsApi); + +// use ENSAnalytics API v1 at /v1/ensanalytics +app.route(ensanalyticsV1BasePath, ensanalyticsApiV1); + +// use Am I Realtime API at /amirealtime +app.route(amIRealtimeBasePath, amIRealtimeApi); + +// use OpenAPI Schema +app.doc31("/openapi.json", { + ...openapiDocumentation, + info: { + ...openapiDocumentation.info, + version: packageJson.version, + }, + servers: [ + ...openapiDocumentation.servers, + { url: `http://localhost:${config.port}`, description: "Local Development" }, + ], +}); + +// will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware +app.get("/health", async (c) => { + return c.json({ message: "fallback ok" }); +}); + +// log hono errors to console +app.onError((error, ctx) => { + logger.error(error); + return errorResponse(ctx, "Internal Server Error"); +}); + +export default app; diff --git a/apps/ensapi/src/handlers/amirealtime-api.routes.ts b/apps/ensapi/src/handlers/amirealtime-api.routes.ts new file mode 100644 index 000000000..c9c6c0c51 --- /dev/null +++ b/apps/ensapi/src/handlers/amirealtime-api.routes.ts @@ -0,0 +1,43 @@ +import { createRoute } from "@hono/zod-openapi"; +import { minutesToSeconds } from "date-fns"; +import { z } from "zod/v4"; + +import type { Duration } from "@ensnode/ensnode-sdk"; +import { makeDurationSchema } from "@ensnode/ensnode-sdk/internal"; + +import { params } from "@/lib/handlers/params.schema"; + +export const basePath = "/amirealtime"; + +// Set default `maxWorstCaseDistance` for `GET /amirealtime` endpoint to one minute. +export const AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE: Duration = minutesToSeconds(1); + +export const amIRealtimeGetMeta = createRoute({ + method: "get", + path: "/", + tags: ["Meta"], + summary: "Check indexing progress", + description: + "Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime", + request: { + query: z.object({ + maxWorstCaseDistance: params.queryParam + .optional() + .default(AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE) + .pipe(makeDurationSchema("maxWorstCaseDistance query param")) + .describe("Maximum acceptable worst-case indexing distance in seconds"), + }), + }, + responses: { + 200: { + description: + "Indexing progress is guaranteed to be within the requested distance of realtime", + }, + 503: { + description: + "Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable", + }, + }, +}); + +export const routes = [amIRealtimeGetMeta]; diff --git a/apps/ensapi/src/handlers/amirealtime-api.test.ts b/apps/ensapi/src/handlers/amirealtime-api.test.ts index b49d57b27..8d807c24e 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.test.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.test.ts @@ -6,10 +6,11 @@ import { type UnixTimestamp, } from "@ensnode/ensnode-sdk"; -import { factory } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import * as middleware from "@/middleware/indexing-status.middleware"; -import amIRealtimeApi, { AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE } from "./amirealtime-api"; // adjust import path as needed +import amIRealtimeApi from "./amirealtime-api"; +import { AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE } from "./amirealtime-api.routes"; vi.mock("@/middleware/indexing-status.middleware", () => ({ indexingStatusMiddleware: vi.fn(), @@ -44,11 +45,11 @@ describe("amirealtime-api", () => { }); }; - let app: ReturnType; + let app: ReturnType; beforeEach(() => { // Create a fresh app instance for each test with middleware registered - app = factory.createApp(); + app = createApp(); app.use(middleware.indexingStatusMiddleware); app.route("/amirealtime", amIRealtimeApi); }); @@ -76,7 +77,10 @@ describe("amirealtime-api", () => { it("should accept valid maxWorstCaseDistance query param (set to `0`)", async () => { // Arrange: set `indexingStatus` context var - arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now }); + arrangeMockedIndexingStatusMiddleware({ + now, + slowestChainIndexingCursor: now, + }); // Act const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=0"); @@ -153,7 +157,10 @@ describe("amirealtime-api", () => { describe("response", () => { it("should return 200 when worstCaseDistance is below maxWorstCaseDistance", async () => { // Arrange: set `indexingStatus` context var - arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 9 }); + arrangeMockedIndexingStatusMiddleware({ + now, + slowestChainIndexingCursor: now - 9, + }); // Act const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=10"); @@ -170,7 +177,10 @@ describe("amirealtime-api", () => { it("should return 200 when worstCaseDistance equals maxWorstCaseDistance", async () => { // Arrange: set `indexingStatus` context var - arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 10 }); + arrangeMockedIndexingStatusMiddleware({ + now, + slowestChainIndexingCursor: now - 10, + }); // Act const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=10"); @@ -187,7 +197,10 @@ describe("amirealtime-api", () => { it("should return 503 when worstCaseDistance exceeds maxWorstCaseDistance", async () => { // Arrange: set `indexingStatus` context var - arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 11 }); + arrangeMockedIndexingStatusMiddleware({ + now, + slowestChainIndexingCursor: now - 11, + }); // Act const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=10"); diff --git a/apps/ensapi/src/handlers/amirealtime-api.ts b/apps/ensapi/src/handlers/amirealtime-api.ts index d87e4f135..7b45d33fb 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.ts @@ -1,87 +1,48 @@ -import { minutesToSeconds } from "date-fns"; -import { describeRoute } from "hono-openapi"; -import { z } from "zod/v4"; - -import type { Duration } from "@ensnode/ensnode-sdk"; -import { makeDurationSchema } from "@ensnode/ensnode-sdk/internal"; - import { errorResponse } from "@/lib/handlers/error-response"; -import { params } from "@/lib/handlers/params.schema"; -import { validate } from "@/lib/handlers/validate"; -import { factory } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; -const app = factory.createApp(); +import { amIRealtimeGetMeta } from "./amirealtime-api.routes"; -// Set default `maxWorstCaseDistance` for `GET /amirealtime` endpoint to one minute. -export const AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE: Duration = minutesToSeconds(1); +const app = createApp(); // allow performance monitoring clients to read HTTP Status for the provided // `maxWorstCaseDistance` param -app.get( - "/", - describeRoute({ - tags: ["Meta"], - summary: "Check indexing progress", - description: - "Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime", - responses: { - 200: { - description: - "Indexing progress is guaranteed to be within the requested distance of realtime", - }, - 503: { - description: - "Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable", - }, - }, - }), - validate( - "query", - z.object({ - maxWorstCaseDistance: params.queryParam - .optional() - .default(AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE) - .pipe(makeDurationSchema("maxWorstCaseDistance query param")) - .describe("Maximum acceptable worst-case indexing distance in seconds"), - }), - ), - async (c) => { - // context must be set by the required middleware - if (c.var.indexingStatus === undefined) { - throw new Error(`Invariant(amirealtime-api): indexingStatusMiddleware required.`); - } - - // return 503 response error with details on prerequisite being unavailable - if (c.var.indexingStatus instanceof Error) { - return errorResponse( - c, - `Invariant(amirealtime-api): Indexing Status has to be resolved successfully before 'maxWorstCaseDistance' can be applied.`, - 503, - ); - } - - const { maxWorstCaseDistance } = c.req.valid("query"); - const { worstCaseDistance, snapshot } = c.var.indexingStatus; - const { slowestChainIndexingCursor } = snapshot; - - // return 503 response error with details on - // requested `maxWorstCaseDistance` vs. actual `worstCaseDistance` - if (worstCaseDistance > maxWorstCaseDistance) { - return errorResponse( - c, - `Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = ${worstCaseDistance}; maxWorstCaseDistance = ${maxWorstCaseDistance}`, - 503, - ); - } - - // return 200 response OK with current details on `maxWorstCaseDistance`, - // `slowestChainIndexingCursor`, and `worstCaseDistance` - return c.json({ - maxWorstCaseDistance, - slowestChainIndexingCursor, - worstCaseDistance, - }); - }, -); +app.openapi(amIRealtimeGetMeta, async (c) => { + // context must be set by the required middleware + if (c.var.indexingStatus === undefined) { + throw new Error(`Invariant(amirealtime-api): indexingStatusMiddleware required.`); + } + + // return 503 response error with details on prerequisite being unavailable + if (c.var.indexingStatus instanceof Error) { + return errorResponse( + c, + `Invariant(amirealtime-api): Indexing Status has to be resolved successfully before 'maxWorstCaseDistance' can be applied.`, + 503, + ); + } + + const { maxWorstCaseDistance } = c.req.valid("query"); + const { worstCaseDistance, snapshot } = c.var.indexingStatus; + const { slowestChainIndexingCursor } = snapshot; + + // return 503 response error with details on + // requested `maxWorstCaseDistance` vs. actual `worstCaseDistance` + if (worstCaseDistance > maxWorstCaseDistance) { + return errorResponse( + c, + `Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = ${worstCaseDistance}; maxWorstCaseDistance = ${maxWorstCaseDistance}`, + 503, + ); + } + + // return 200 response OK with current details on `maxWorstCaseDistance`, + // `slowestChainIndexingCursor`, and `worstCaseDistance` + return c.json({ + maxWorstCaseDistance, + slowestChainIndexingCursor, + worstCaseDistance, + }); +}); export default app; 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..4d9388c05 --- /dev/null +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.routes.ts @@ -0,0 +1,131 @@ +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. + */ +export 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 +export const referrerAddressSchema = z.object({ + referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"), +}); + +// Editions query parameter schema +export 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 referralLeaderboardV1GetMeta = 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 referrerDetailV1GetMeta = 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 editionConfigSetGetMeta = 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 = [ + referralLeaderboardV1GetMeta, + referrerDetailV1GetMeta, + editionConfigSetGetMeta, +]; diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts index aa72ef716..b295a7902 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api-v1.test.ts @@ -91,26 +91,41 @@ describe("/v1/ensanalytics", () => { const allPossibleReferrersIterator = allPossibleReferrers[Symbol.iterator](); // Arrange: create the test client from the app instance - const client = testClient(app); + const client: any = 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()) + .$get( + { + query: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" }, + }, + {}, + ) + .then((r: Response) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // 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()) + .$get( + { + query: { edition, recordsPerPage: `${recordsPerPage}`, page: "2" }, + }, + {}, + ) + .then((r: Response) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // 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()) + .$get( + { + query: { edition, recordsPerPage: `${recordsPerPage}`, page: "3" }, + }, + {}, + ) + .then((r: Response) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // Assert: 1st page results @@ -208,14 +223,19 @@ describe("/v1/ensanalytics", () => { }); // Arrange: create the test client from the app instance - const client = testClient(app); + const client: any = 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()) + .$get( + { + query: { edition, recordsPerPage: `${recordsPerPage}`, page: "1" }, + }, + {}, + ) + .then((r: Response) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // Assert: empty page results @@ -259,8 +279,22 @@ describe("/v1/ensanalytics", () => { // Mock edition set middleware to provide a mock edition set const mockEditionConfigSet = new Map([ - ["test-edition-a", { slug: "test-edition-a", displayName: "Edition A", rules: {} as any }], - ["test-edition-b", { slug: "test-edition-b", displayName: "Edition B", rules: {} as any }], + [ + "test-edition-a", + { + slug: "test-edition-a", + displayName: "Edition A", + rules: {} as any, + }, + ], + [ + "test-edition-b", + { + slug: "test-edition-b", + displayName: "Edition B", + rules: {} as any, + }, + ], ]); vi.mocked(editionSetMiddleware.referralProgramEditionConfigSetMiddleware).mockImplementation( async (c, next) => { @@ -278,13 +312,19 @@ describe("/v1/ensanalytics", () => { }); // Arrange: create the test client from the app instance - const client = testClient(app); + const client: any = 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" } }, + { + query: { + edition: invalidEdition, + recordsPerPage: `${recordsPerPage}`, + page: "1", + }, + }, {}, ); const responseData = await httpResponse.json(); @@ -802,7 +842,10 @@ describe("/v1/ensanalytics", () => { 100, parseTimestamp("2025-12-01T00:00:00Z"), parseTimestamp("2025-12-31T23:59:59Z"), - { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + { + chainId: 1, + address: "0x0000000000000000000000000000000000000000", + }, new URL("https://example.com/rules"), ), }, @@ -817,7 +860,10 @@ describe("/v1/ensanalytics", () => { 100, parseTimestamp("2026-03-01T00:00:00Z"), parseTimestamp("2026-03-31T23:59:59Z"), - { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + { + chainId: 1, + address: "0x0000000000000000000000000000000000000000", + }, new URL("https://example.com/rules"), ), }, @@ -832,7 +878,10 @@ describe("/v1/ensanalytics", () => { 100, parseTimestamp("2026-06-01T00:00:00Z"), parseTimestamp("2026-06-30T23:59:59Z"), - { chainId: 1, address: "0x0000000000000000000000000000000000000000" }, + { + chainId: 1, + address: "0x0000000000000000000000000000000000000000", + }, new URL("https://example.com/rules"), ), }, diff --git a/apps/ensapi/src/handlers/ensanalytics-api-v1.ts b/apps/ensapi/src/handlers/ensanalytics-api-v1.ts index 5806befb0..925340fef 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,287 @@ 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 { + editionConfigSetGetMeta, + referralLeaderboardV1GetMeta, + referrerDetailV1GetMeta, +} 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(referralLeaderboardV1GetMeta, 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(referrerDetailV1GetMeta, 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/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 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(editionConfigSetGetMeta, 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/handlers/ensanalytics-api.routes.ts b/apps/ensapi/src/handlers/ensanalytics-api.routes.ts new file mode 100644 index 000000000..69b64d955 --- /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) +export 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 +export const referrerAddressSchema = z.object({ + referrer: makeLowercaseAddressSchema("Referrer address").describe("Referrer Ethereum address"), +}); + +export const referrerLeaderboardGetMeta = 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 referrerDetailGetMeta = 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 = [referrerLeaderboardGetMeta, referrerDetailGetMeta]; diff --git a/apps/ensapi/src/handlers/ensanalytics-api.test.ts b/apps/ensapi/src/handlers/ensanalytics-api.test.ts index 64229e014..cb2506f24 100644 --- a/apps/ensapi/src/handlers/ensanalytics-api.test.ts +++ b/apps/ensapi/src/handlers/ensanalytics-api.test.ts @@ -54,25 +54,25 @@ describe("/ensanalytics", () => { const allPossibleReferrersIterator = allPossibleReferrers[Symbol.iterator](); // Arrange: create the test client from the app instance - const client = testClient(app); + const client: any = 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((r: Response) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // Act: send test request to fetch 2nd page const responsePage2 = await client.referrers .$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "2" } }, {}) - .then((r) => r.json()) + .then((r: Response) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // Act: send test request to fetch 3rd page const responsePage3 = await client.referrers .$get({ query: { recordsPerPage: `${recordsPerPage}`, page: "3" } }, {}) - .then((r) => r.json()) + .then((r: Response) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // Assert: 1st page results @@ -145,13 +145,13 @@ describe("/ensanalytics", () => { }); // Arrange: create the test client from the app instance - const client = testClient(app); + const client: any = 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((r: Response) => r.json()) .then(deserializeReferrerLeaderboardPageResponse); // Assert: empty page results diff --git a/apps/ensapi/src/handlers/ensanalytics-api.ts b/apps/ensapi/src/handlers/ensanalytics-api.ts index 1912a8bbf..8476f6e0a 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 { referrerDetailGetMeta, referrerLeaderboardGetMeta } 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(referrerLeaderboardGetMeta, 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(referrerDetailGetMeta, 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/handlers/ensnode-api.routes.ts b/apps/ensapi/src/handlers/ensnode-api.routes.ts new file mode 100644 index 000000000..12ed983d3 --- /dev/null +++ b/apps/ensapi/src/handlers/ensnode-api.routes.ts @@ -0,0 +1,54 @@ +import { createRoute } from "@hono/zod-openapi"; + +import { + makeENSApiPublicConfigSchema, + makeIndexingStatusResponseSchema, +} from "@ensnode/ensnode-sdk/internal"; + +export const basePath = "/api"; + +export const configGetMeta = createRoute({ + method: "get", + path: "/config", + tags: ["Meta"], + summary: "Get ENSApi Public Config", + description: "Gets the public config of the ENSApi instance", + responses: { + 200: { + description: "Successfully retrieved ENSApi public config", + content: { + "application/json": { + schema: makeENSApiPublicConfigSchema(), + }, + }, + }, + }, +}); + +export const indexingStatusGetMeta = createRoute({ + method: "get", + path: "/indexing-status", + tags: ["Meta"], + summary: "Get ENSIndexer Indexing Status", + description: "Returns the indexing status snapshot most recently captured from ENSIndexer", + responses: { + 200: { + description: "Successfully retrieved indexing status", + content: { + "application/json": { + schema: makeIndexingStatusResponseSchema(), + }, + }, + }, + 503: { + description: "Indexing status snapshot unavailable", + content: { + "application/json": { + schema: makeIndexingStatusResponseSchema(), + }, + }, + }, + }, +}); + +export const routes = [configGetMeta, indexingStatusGetMeta]; diff --git a/apps/ensapi/src/handlers/ensnode-api.ts b/apps/ensapi/src/handlers/ensnode-api.ts index 46d458dad..77df7ec7e 100644 --- a/apps/ensapi/src/handlers/ensnode-api.ts +++ b/apps/ensapi/src/handlers/ensnode-api.ts @@ -1,7 +1,3 @@ -import config from "@/config"; - -import { describeRoute, resolver as validationResolver } from "hono-openapi"; - import { IndexingStatusResponseCodes, type IndexingStatusResponseError, @@ -9,102 +5,59 @@ import { serializeENSApiPublicConfig, serializeIndexingStatusResponse, } from "@ensnode/ensnode-sdk"; -import { - makeENSApiPublicConfigSchema, - makeIndexingStatusResponseSchema, -} from "@ensnode/ensnode-sdk/internal"; import { buildEnsApiPublicConfig } from "@/config/config.schema"; -import { factory } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; +import { basePath, configGetMeta, indexingStatusGetMeta } from "./ensnode-api.routes"; import ensnodeGraphQLApi from "./ensnode-graphql-api"; import nameTokensApi from "./name-tokens-api"; +import { basePath as nameTokensBasePath } from "./name-tokens-api.routes"; import registrarActionsApi from "./registrar-actions-api"; +import { basePath as registrarActionsBasePath } from "./registrar-actions-api.routes"; import resolutionApi from "./resolution-api"; +import { basePath as resolutionBasePath } from "./resolution-api.routes"; -const app = factory.createApp(); +const app = createApp(); -app.get( - "/config", - describeRoute({ - tags: ["Meta"], - summary: "Get ENSApi Public Config", - description: "Gets the public config of the ENSApi instance", - responses: { - 200: { - description: "Successfully retrieved ENSApi public config", - content: { - "application/json": { - schema: validationResolver(makeENSApiPublicConfigSchema()), - }, - }, - }, - }, - }), - async (c) => { - const ensApiPublicConfig = buildEnsApiPublicConfig(config); - return c.json(serializeENSApiPublicConfig(ensApiPublicConfig)); - }, -); +app.openapi(configGetMeta, async (c) => { + const config = (await import("@/config")).default; + const ensApiPublicConfig = buildEnsApiPublicConfig(config); + return c.json(serializeENSApiPublicConfig(ensApiPublicConfig)); +}); -app.get( - "/indexing-status", - describeRoute({ - tags: ["Meta"], - summary: "Get ENSIndexer Indexing Status", - description: "Returns the indexing status snapshot most recently captured from ENSIndexer", - responses: { - 200: { - description: "Successfully retrieved indexing status", - content: { - "application/json": { - schema: validationResolver(makeIndexingStatusResponseSchema()), - }, - }, - }, - 503: { - description: "Indexing status snapshot unavailable", - content: { - "application/json": { - schema: validationResolver(makeIndexingStatusResponseSchema()), - }, - }, - }, - }, - }), - async (c) => { - // context must be set by the required middleware - if (c.var.indexingStatus === undefined) { - throw new Error(`Invariant(indexing-status): indexingStatusMiddleware required`); - } +app.openapi(indexingStatusGetMeta, async (c) => { + // context must be set by the required middleware + if (c.var.indexingStatus === undefined) { + throw new Error(`Invariant(indexing-status): indexingStatusMiddleware required`); + } - if (c.var.indexingStatus instanceof Error) { - return c.json( - serializeIndexingStatusResponse({ - responseCode: IndexingStatusResponseCodes.Error, - } satisfies IndexingStatusResponseError), - 503, - ); - } - - // return successful response using the indexing status projection from the middleware context + if (c.var.indexingStatus instanceof Error) { return c.json( serializeIndexingStatusResponse({ - responseCode: IndexingStatusResponseCodes.Ok, - realtimeProjection: c.var.indexingStatus, - } satisfies IndexingStatusResponseOk), + responseCode: IndexingStatusResponseCodes.Error, + } satisfies IndexingStatusResponseError), + 503, ); - }, -); + } + + // return successful response using the indexing status projection from the middleware context + return c.json( + serializeIndexingStatusResponse({ + responseCode: IndexingStatusResponseCodes.Ok, + realtimeProjection: c.var.indexingStatus, + } satisfies IndexingStatusResponseOk), + ); +}); // Name Tokens API -app.route("/name-tokens", nameTokensApi); +app.route(nameTokensBasePath.replace(basePath, ""), nameTokensApi); // Registrar Actions API -app.route("/registrar-actions", registrarActionsApi); +app.route(registrarActionsBasePath.replace(basePath, ""), registrarActionsApi); // Resolution API -app.route("/resolve", resolutionApi); +app.route(resolutionBasePath.replace(basePath, ""), resolutionApi); // ENSNode GraphQL API app.route("/graphql", ensnodeGraphQLApi); diff --git a/apps/ensapi/src/handlers/ensnode-graphql-api.ts b/apps/ensapi/src/handlers/ensnode-graphql-api.ts index adf92d7c9..da767eb70 100644 --- a/apps/ensapi/src/handlers/ensnode-graphql-api.ts +++ b/apps/ensapi/src/handlers/ensnode-graphql-api.ts @@ -1,7 +1,7 @@ -import { factory } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { requireCorePluginMiddleware } from "@/middleware/require-core-plugin.middleware"; -const app = factory.createApp(); +const app = createApp(); app.use(requireCorePluginMiddleware("ensv2")); app.use(async (c) => { diff --git a/apps/ensapi/src/handlers/name-tokens-api.routes.ts b/apps/ensapi/src/handlers/name-tokens-api.routes.ts new file mode 100644 index 000000000..d5d9edb04 --- /dev/null +++ b/apps/ensapi/src/handlers/name-tokens-api.routes.ts @@ -0,0 +1,83 @@ +import { createRoute } from "@hono/zod-openapi"; +import { z } from "zod/v4"; + +import { + ErrorResponseSchema, + makeNameTokensResponseSchema, + makeNodeSchema, +} from "@ensnode/ensnode-sdk/internal"; + +import { params } from "@/lib/handlers/params.schema"; + +export const basePath = "/api/name-tokens"; + +/** + * Request Query Schema + * + * Name Tokens API can be requested by either `name` or `domainId`, and + * can never be requested by both, or neither. + */ +export const nameTokensQuerySchema = z + .object({ + domainId: makeNodeSchema("request.domainId").optional().describe("Domain node hash identifier"), + name: params.name.optional().describe("ENS name to look up tokens for"), + }) + .refine((data) => (data.domainId !== undefined) !== (data.name !== undefined), { + message: "Exactly one of 'domainId' or 'name' must be provided", + }); + +export const nameTokensGetMeta = createRoute({ + method: "get", + path: "/", + tags: ["Explore"], + summary: "Get Name Tokens", + description: "Returns name tokens for the requested identifier (domainId or name)", + request: { + query: nameTokensQuerySchema, + }, + responses: { + 200: { + description: "Name tokens known", + content: { + "application/json": { + schema: makeNameTokensResponseSchema("Name Tokens Response", true), + }, + }, + }, + 400: { + description: "Invalid input", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 404: { + description: "Name tokens not indexed", + content: { + "application/json": { + schema: makeNameTokensResponseSchema("Name Tokens Response", true), + }, + }, + }, + 500: { + description: "Internal server error", + content: { + "application/json": { + schema: ErrorResponseSchema, + }, + }, + }, + 503: { + description: + "Service unavailable - Name Tokens API prerequisites not met (indexing status not ready or required plugins not activated)", + content: { + "application/json": { + schema: makeNameTokensResponseSchema("Name Tokens Response", true), + }, + }, + }, + }, +}); + +export const routes = [nameTokensGetMeta]; diff --git a/apps/ensapi/src/handlers/name-tokens-api.ts b/apps/ensapi/src/handlers/name-tokens-api.ts index fead1a0da..75c24119d 100644 --- a/apps/ensapi/src/handlers/name-tokens-api.ts +++ b/apps/ensapi/src/handlers/name-tokens-api.ts @@ -1,8 +1,4 @@ -import config from "@/config"; - -import { describeRoute, resolver as validationResolver } from "hono-openapi"; import { namehash } from "viem"; -import { z } from "zod/v4"; import { ENS_ROOT, @@ -15,46 +11,21 @@ import { type PluginName, serializeNameTokensResponse, } from "@ensnode/ensnode-sdk"; -import { - ErrorResponseSchema, - makeNameTokensResponseSchema, - makeNodeSchema, -} from "@ensnode/ensnode-sdk/internal"; - -import { params } from "@/lib/handlers/params.schema"; -import { validate } from "@/lib/handlers/validate"; -import { factory } from "@/lib/hono-factory"; + +import { createApp } from "@/lib/hono-factory"; import { findRegisteredNameTokensForDomain } from "@/lib/name-tokens/find-name-tokens-for-domain"; import { getIndexedSubregistries } from "@/lib/name-tokens/get-indexed-subregistries"; import { nameTokensApiMiddleware } from "@/middleware/name-tokens.middleware"; -const app = factory.createApp(); +import { nameTokensGetMeta } from "./name-tokens-api.routes"; -const indexedSubregistries = getIndexedSubregistries( - config.namespace, - config.ensIndexerPublicConfig.plugins as PluginName[], -); +const app = createApp(); // Middleware managing access to Name Tokens API route. // It makes the route available if all prerequisites are met, // and if not returns the appropriate HTTP 503 (Service Unavailable) error. app.use(nameTokensApiMiddleware); -/** - * Request Query Schema - * - * Name Tokens API can be requested by either `name` or `domainId`, and - * can never be requested by both, or neither. - */ -const nameTokensQuerySchema = z - .object({ - domainId: makeNodeSchema("request.domainId").optional().describe("Domain node hash identifier"), - name: params.name.optional().describe("ENS name to look up tokens for"), - }) - .refine((data) => (data.domainId !== undefined) !== (data.name !== undefined), { - message: "Exactly one of 'domainId' or 'name' must be provided", - }); - /** * Factory function for creating a 404 Name Tokens Not Indexed error response */ @@ -69,163 +40,116 @@ const makeNameTokensNotIndexedResponse = ( }, }); -app.get( - "/", - describeRoute({ - tags: ["Explore"], - summary: "Get Name Tokens", - description: "Returns name tokens for the requested identifier (domainId or name)", - responses: { - 200: { - description: "Name tokens known", - content: { - "application/json": { - schema: validationResolver(makeNameTokensResponseSchema("Name Tokens Response", true)), - }, - }, - }, - 400: { - description: "Invalid input", - content: { - "application/json": { - schema: validationResolver(ErrorResponseSchema), - }, - }, - }, - 404: { - description: "Name tokens not indexed", - content: { - "application/json": { - schema: validationResolver(makeNameTokensResponseSchema("Name Tokens Response", true)), - }, - }, - }, - 500: { - description: "Internal server error", - content: { - "application/json": { - schema: validationResolver(ErrorResponseSchema), - }, +app.openapi(nameTokensGetMeta, async (c): Promise => { + // Invariant: context must be set by the required middleware + if (c.var.indexingStatus === undefined) { + return c.json( + serializeNameTokensResponse({ + responseCode: NameTokensResponseCodes.Error, + errorCode: NameTokensResponseErrorCodes.IndexingStatusUnsupported, + error: { + message: "Name Tokens API is not available yet", + details: "Indexing status middleware is required but not initialized.", }, - }, - 503: { - description: - "Service unavailable - Name Tokens API prerequisites not met (indexing status not ready or required plugins not activated)", - content: { - "application/json": { - schema: validationResolver(makeNameTokensResponseSchema("Name Tokens Response", true)), - }, + }), + 503, + ); + } + + // Check if Indexing Status resolution failed. + if (c.var.indexingStatus instanceof Error) { + return c.json( + serializeNameTokensResponse({ + responseCode: NameTokensResponseCodes.Error, + errorCode: NameTokensResponseErrorCodes.IndexingStatusUnsupported, + error: { + message: "Name Tokens API is not available yet", + details: + "Indexing status has not yet reached the required state to enable the Name Tokens API.", }, - }, - }, - }), - validate("query", nameTokensQuerySchema), - async (c) => { - // Invariant: context must be set by the required middleware - if (c.var.indexingStatus === undefined) { - return c.json( - serializeNameTokensResponse({ - responseCode: NameTokensResponseCodes.Error, - errorCode: NameTokensResponseErrorCodes.IndexingStatusUnsupported, - error: { - message: "Name Tokens API is not available yet", - details: "Indexing status middleware is required but not initialized.", - }, - }), - 503, - ); - } + }), + 503, + ); + } - // Check if Indexing Status resolution failed. - if (c.var.indexingStatus instanceof Error) { - return c.json( - serializeNameTokensResponse({ - responseCode: NameTokensResponseCodes.Error, - errorCode: NameTokensResponseErrorCodes.IndexingStatusUnsupported, - error: { - message: "Name Tokens API is not available yet", - details: - "Indexing status has not yet reached the required state to enable the Name Tokens API.", - }, - }), - 503, - ); - } + const request = c.req.valid("query") satisfies NameTokensRequest; + let domainId: Node; - const request = c.req.valid("query") satisfies NameTokensRequest; - let domainId: Node; + // Lazy config import to avoid module-scope config dependency + const config = (await import("@/config")).default; + const indexedSubregistries = getIndexedSubregistries( + config.namespace, + config.ensIndexerPublicConfig.plugins as PluginName[], + ); - if (request.name !== undefined) { - const { name } = request; + if (request.name !== undefined) { + const { name } = request; - // return 404 when the requested name was the ENS Root - if (name === ENS_ROOT) { - return c.json( - serializeNameTokensResponse( - makeNameTokensNotIndexedResponse( - `The 'name' param must not be ENS Root, no tokens exist for it.`, - ), + // return 404 when the requested name was the ENS Root + if (name === ENS_ROOT) { + return c.json( + serializeNameTokensResponse( + makeNameTokensNotIndexedResponse( + `The 'name' param must not be ENS Root, no tokens exist for it.`, ), - 404, - ); - } - - const parentNode = namehash(getParentNameFQDN(name)); - const subregistry = indexedSubregistries.find( - (subregistry) => subregistry.node === parentNode, + ), + 404, ); - - // Return 404 response with error code for Name Tokens Not Indexed when - // the parent name of the requested name does not match any of the - // actively indexed subregistries. - if (!subregistry) { - return c.json( - serializeNameTokensResponse( - makeNameTokensNotIndexedResponse( - `This ENSNode instance has not been configured to index tokens for the requested name: '${name}`, - ), - ), - 404, - ); - } - - domainId = namehash(name); - } else if (request.domainId !== undefined) { - domainId = request.domainId; - } else { - // This should never happen due to Zod validation, but TypeScript needs this - throw new Error("Invariant(name-tokens-api): Either name or domainId must be provided"); } - const { omnichainSnapshot } = c.var.indexingStatus.snapshot; - const accurateAsOf = omnichainSnapshot.omnichainIndexingCursor; - - const registeredNameTokens = await findRegisteredNameTokensForDomain(domainId, accurateAsOf); + const parentNode = namehash(getParentNameFQDN(name)); + const subregistry = indexedSubregistries.find((subregistry) => subregistry.node === parentNode); // Return 404 response with error code for Name Tokens Not Indexed when - // no name tokens were found for the domain ID associated with - // the requested name. - if (!registeredNameTokens) { - const errorMessageSubject = - request.name !== undefined ? `name: '${request.name}'` : `domain ID: '${request.domainId}'`; - + // the parent name of the requested name does not match any of the + // actively indexed subregistries. + if (!subregistry) { return c.json( serializeNameTokensResponse( makeNameTokensNotIndexedResponse( - `No Name Tokens were indexed by this ENSNode instance for the requested ${errorMessageSubject}.`, + `This ENSNode instance has not been configured to index tokens for the requested name: '${name}'`, ), ), 404, ); } + domainId = namehash(name); + } else if (request.domainId !== undefined) { + domainId = request.domainId; + } else { + // This should never happen due to Zod validation, but TypeScript needs this + throw new Error("Invariant(name-tokens-api): Either name or domainId must be provided"); + } + + const { omnichainSnapshot } = c.var.indexingStatus.snapshot; + const accurateAsOf = omnichainSnapshot.omnichainIndexingCursor; + + const registeredNameTokens = await findRegisteredNameTokensForDomain(domainId, accurateAsOf); + + // Return 404 response with error code for Name Tokens Not Indexed when + // no name tokens were found for the domain ID associated with + // the requested name. + if (!registeredNameTokens) { + const errorMessageSubject = + request.name !== undefined ? `name: '${request.name}'` : `domain ID: '${request.domainId}'`; + return c.json( - serializeNameTokensResponse({ - responseCode: NameTokensResponseCodes.Ok, - registeredNameTokens, - }), + serializeNameTokensResponse( + makeNameTokensNotIndexedResponse( + `No Name Tokens were indexed by this ENSNode instance for the requested ${errorMessageSubject}.`, + ), + ), + 404, ); - }, -); + } + + return c.json( + serializeNameTokensResponse({ + responseCode: NameTokensResponseCodes.Ok, + registeredNameTokens, + }), + ); +}); export default app; diff --git a/apps/ensapi/src/handlers/registrar-actions-api.routes.ts b/apps/ensapi/src/handlers/registrar-actions-api.routes.ts new file mode 100644 index 000000000..4e762fe6a --- /dev/null +++ b/apps/ensapi/src/handlers/registrar-actions-api.routes.ts @@ -0,0 +1,130 @@ +import { createRoute } from "@hono/zod-openapi"; +import { z } from "zod/v4"; + +import { + RECORDS_PER_PAGE_DEFAULT, + RECORDS_PER_PAGE_MAX, + RegistrarActionsOrders, +} from "@ensnode/ensnode-sdk"; +import { + makeLowercaseAddressSchema, + makeNodeSchema, + makePositiveIntegerSchema, + makeUnixTimestampSchema, +} from "@ensnode/ensnode-sdk/internal"; + +import { params } from "@/lib/handlers/params.schema"; + +export const basePath = "/api/registrar-actions"; + +// Shared query schema for registrar actions +export const registrarActionsQuerySchema = z + .object({ + orderBy: z + .enum(RegistrarActionsOrders) + .default(RegistrarActionsOrders.LatestRegistrarActions) + .describe("Order of results"), + + page: params.queryParam + .optional() + .default(1) + .pipe(z.coerce.number()) + .pipe(makePositiveIntegerSchema("page")) + .describe("Page number for pagination"), + + recordsPerPage: params.queryParam + .optional() + .default(RECORDS_PER_PAGE_DEFAULT) + .pipe(z.coerce.number()) + .pipe(makePositiveIntegerSchema("recordsPerPage").max(RECORDS_PER_PAGE_MAX)) + .describe("Number of records per page"), + + withReferral: params.boolstring + .optional() + .default(false) + .describe("Filter to only include actions with referrals"), + + decodedReferrer: makeLowercaseAddressSchema("decodedReferrer") + .optional() + .describe("Filter by decoded referrer address"), + + beginTimestamp: params.queryParam + .pipe(z.coerce.number()) + .pipe(makeUnixTimestampSchema("beginTimestamp")) + .optional() + .describe("Filter actions at or after this Unix timestamp"), + + endTimestamp: params.queryParam + .pipe(z.coerce.number()) + .pipe(makeUnixTimestampSchema("endTimestamp")) + .optional() + .describe("Filter actions at or before this Unix timestamp"), + }) + .refine( + (data) => { + // If both timestamps are provided, endTimestamp must be >= beginTimestamp + if (data.beginTimestamp !== undefined && data.endTimestamp !== undefined) { + return data.endTimestamp >= data.beginTimestamp; + } + return true; + }, + { + message: "endTimestamp must be greater than or equal to beginTimestamp", + path: ["endTimestamp"], + }, + ); + +// Parent node parameter schema +export const parentNodeParamSchema = z.object({ + parentNode: makeNodeSchema("parentNode param").describe( + "Parent node to filter registrar actions", + ), +}); + +export const registrarActionsGetMeta = createRoute({ + method: "get", + path: "/", + tags: ["Explore"], + summary: "Get Registrar Actions", + description: "Returns all registrar actions with optional filtering and pagination", + request: { + query: registrarActionsQuerySchema, + }, + responses: { + 200: { + description: "Successfully retrieved registrar actions", + }, + 400: { + description: "Invalid query", + }, + 500: { + description: "Internal server error", + }, + }, +}); + +export const registrarActionsByParentNodeGetMeta = createRoute({ + method: "get", + path: "/:parentNode", + tags: ["Explore"], + summary: "Get Registrar Actions by Parent Node", + description: + "Returns registrar actions filtered by parent node hash with optional additional filtering and pagination", + request: { + params: parentNodeParamSchema, + query: registrarActionsQuerySchema, + }, + responses: { + 200: { + description: "Successfully retrieved registrar actions", + }, + 400: { + description: "Invalid input", + }, + 500: { + description: "Internal server error", + }, + }, +}); + +export const routes = [registrarActionsGetMeta, registrarActionsByParentNodeGetMeta]; diff --git a/apps/ensapi/src/handlers/registrar-actions-api.ts b/apps/ensapi/src/handlers/registrar-actions-api.ts index 69bcea86a..67b11001e 100644 --- a/apps/ensapi/src/handlers/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/registrar-actions-api.ts @@ -1,34 +1,28 @@ -import { describeRoute } from "hono-openapi"; -import { z } from "zod/v4"; +import type { z } from "zod/v4"; import { buildPageContext, type Node, - RECORDS_PER_PAGE_DEFAULT, - RECORDS_PER_PAGE_MAX, type RegistrarActionsFilter, - RegistrarActionsOrders, RegistrarActionsResponseCodes, type RegistrarActionsResponseError, type RegistrarActionsResponseOk, registrarActionsFilter, serializeRegistrarActionsResponse, } from "@ensnode/ensnode-sdk"; -import { - makeLowercaseAddressSchema, - makeNodeSchema, - makePositiveIntegerSchema, - makeUnixTimestampSchema, -} from "@ensnode/ensnode-sdk/internal"; -import { params } from "@/lib/handlers/params.schema"; -import { validate } from "@/lib/handlers/validate"; -import { factory } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { findRegistrarActions } from "@/lib/registrar-actions/find-registrar-actions"; import { registrarActionsApiMiddleware } from "@/middleware/registrar-actions.middleware"; -const app = factory.createApp(); +import { + registrarActionsByParentNodeGetMeta, + registrarActionsGetMeta, + type registrarActionsQuerySchema, +} from "./registrar-actions-api.routes"; + +const app = createApp(); const logger = makeLogger("registrar-actions-api"); @@ -36,63 +30,6 @@ const logger = makeLogger("registrar-actions-api"); // It makes the routes available if all prerequisites are met. app.use(registrarActionsApiMiddleware); -// Shared query schema for registrar actions -const registrarActionsQuerySchema = z - .object({ - orderBy: z - .enum(RegistrarActionsOrders) - .default(RegistrarActionsOrders.LatestRegistrarActions) - .describe("Order of results"), - - page: params.queryParam - .optional() - .default(1) - .pipe(z.coerce.number()) - .pipe(makePositiveIntegerSchema("page")) - .describe("Page number for pagination"), - - recordsPerPage: params.queryParam - .optional() - .default(RECORDS_PER_PAGE_DEFAULT) - .pipe(z.coerce.number()) - .pipe(makePositiveIntegerSchema("recordsPerPage").max(RECORDS_PER_PAGE_MAX)) - .describe("Number of records per page"), - - withReferral: params.boolstring - .optional() - .default(false) - .describe("Filter to only include actions with referrals"), - - decodedReferrer: makeLowercaseAddressSchema("decodedReferrer") - .optional() - .describe("Filter by decoded referrer address"), - - beginTimestamp: params.queryParam - .pipe(z.coerce.number()) - .pipe(makeUnixTimestampSchema("beginTimestamp")) - .optional() - .describe("Filter actions at or after this Unix timestamp"), - - endTimestamp: params.queryParam - .pipe(z.coerce.number()) - .pipe(makeUnixTimestampSchema("endTimestamp")) - .optional() - .describe("Filter actions at or before this Unix timestamp"), - }) - .refine( - (data) => { - // If both timestamps are provided, endTimestamp must be >= beginTimestamp - if (data.beginTimestamp !== undefined && data.endTimestamp !== undefined) { - return data.endTimestamp >= data.beginTimestamp; - } - return true; - }, - { - message: "endTimestamp must be greater than or equal to beginTimestamp", - path: ["endTimestamp"], - }, - ); - // Shared business logic for fetching registrar actions async function fetchRegistrarActions( parentNode: Node | undefined, @@ -151,159 +88,79 @@ async function fetchRegistrarActions( * Get Registrar Actions (all records) * * Example: `GET /api/registrar-actions` - * - * @see {@link app.get("/:parentNode")} for response documentation */ -app.get( - "/", - describeRoute({ - tags: ["Explore"], - summary: "Get Registrar Actions", - description: "Returns all registrar actions with optional filtering and pagination", - responses: { - 200: { - description: "Successfully retrieved registrar actions", - }, - 400: { - description: "Invalid query", - }, - 500: { - description: "Internal server error", - }, - }, - }), - validate("query", registrarActionsQuerySchema), - async (c) => { - try { - const query = c.req.valid("query"); - const { registrarActions, pageContext } = await fetchRegistrarActions(undefined, query); - - // respond with success response - return c.json( - serializeRegistrarActionsResponse({ - responseCode: RegistrarActionsResponseCodes.Ok, - registrarActions, - pageContext, - } satisfies RegistrarActionsResponseOk), - ); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - logger.error(errorMessage); - - // respond with 500 error response - return c.json( - serializeRegistrarActionsResponse({ - responseCode: RegistrarActionsResponseCodes.Error, - error: { - message: `Registrar Actions API Response is unavailable`, - }, - } satisfies RegistrarActionsResponseError), - 500, - ); - } - }, -); +app.openapi(registrarActionsGetMeta, async (c) => { + try { + const query = c.req.valid("query"); + const { registrarActions, pageContext } = await fetchRegistrarActions(undefined, query); + + // respond with success response + return c.json( + serializeRegistrarActionsResponse({ + responseCode: RegistrarActionsResponseCodes.Ok, + registrarActions, + pageContext, + } satisfies RegistrarActionsResponseOk), + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + logger.error(errorMessage); + + // respond with 500 error response + return c.json( + serializeRegistrarActionsResponse({ + responseCode: RegistrarActionsResponseCodes.Error, + error: { + message: `Registrar Actions API Response is unavailable`, + }, + } satisfies RegistrarActionsResponseError), + 500, + ); + } +}); /** * Get Registrar Actions (filtered by parent node) - * - * Examples of use: - * - all records associated with `namehash('eth')` parent node: - * `GET /api/registrar-actions/0x93cdeb708b7545dc668eb9280176169d1c33cfd8ed6f04690a0bcc88a93fc4ae` - * - all records associated with `namehash('base.eth')` parent node: - * `GET /api/registrar-actions/0xff1e3c0eb00ec714e34b6114125fbde1dea2f24a72fbf672e7b7fd5690328e10` - * - all records associated with `namehash('linea.eth')` parent node: - * `GET /api/registrar-actions/0x527aac89ac1d1de5dd84cff89ec92c69b028ce9ce3fa3d654882474ab4402ec3` - * - * Examples of use with testnets: - * - all records associated with `namehash('linea-sepolia.eth')` parent node: - * `GET /api/registrar-actions/0x1944d8f922dbda424d5bb8181be5344d513cd0210312d2dcccd37d54c11a17de` - * - all records associated with `namehash('basetest.eth')` parent node: - * `GET /api/registrar-actions/0x646204f07e7fcd394a508306bf1148a1e13d14287fa33839bf9ad63755f547c6` - * - * Responds with: - * - 400 error response for bad input, such as: - * - (if provided) `page` search param is not a positive integer. - * - (if provided) `recordsPerPage` search param is not - * a positive integer <= {@link RECORDS_PER_PAGE_MAX}. - * - (if provided) `orderBy` search param is not part of {@link RegistrarActionsOrders}. - * - (if provided) `beginTimestamp` or `endTimestamp` search params are not valid Unix timestamps. - * - (if both provided) `endTimestamp` is less than `beginTimestamp`. - * - 500 error response for cases such as: - * - Connected ENSNode has not all required plugins set to active. - * - Connected ENSNode is not in `omnichainStatus` of either - * {@link OmnichainIndexingStatusIds.Completed} or - * {@link OmnichainIndexingStatusIds.Following}. - * - unknown server error occurs. */ -app.get( - "/:parentNode", - describeRoute({ - tags: ["Explore"], - summary: "Get Registrar Actions by Parent Node", - description: - "Returns registrar actions filtered by parent node hash with optional additional filtering and pagination", - responses: { - 200: { - description: "Successfully retrieved registrar actions", - }, - 400: { - description: "Invalid input", - }, - 500: { - description: "Internal server error", - }, - }, - }), - validate( - "param", - z.object({ - parentNode: makeNodeSchema("parentNode param").describe( - "Parent node to filter registrar actions", - ), - }), - ), - validate("query", registrarActionsQuerySchema), - async (c) => { - try { - // Middleware ensures indexingStatus is available and not an Error - // This check is for TypeScript type safety - if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) { - throw new Error("Invariant violation: indexingStatus should be validated by middleware"); - } - - const { parentNode } = c.req.valid("param"); - const query = c.req.valid("query"); - const { registrarActions, pageContext } = await fetchRegistrarActions(parentNode, query); - - // Get the accurateAsOf timestamp from the slowest chain indexing cursor - const accurateAsOf = c.var.indexingStatus.snapshot.slowestChainIndexingCursor; - - // respond with success response - return c.json( - serializeRegistrarActionsResponse({ - responseCode: RegistrarActionsResponseCodes.Ok, - registrarActions, - pageContext, - accurateAsOf, - } satisfies RegistrarActionsResponseOk), - ); - } catch (error) { - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - logger.error(errorMessage); - - // respond with 500 error response - return c.json( - serializeRegistrarActionsResponse({ - responseCode: RegistrarActionsResponseCodes.Error, - error: { - message: `Registrar Actions API Response is unavailable`, - }, - } satisfies RegistrarActionsResponseError), - 500, - ); +app.openapi(registrarActionsByParentNodeGetMeta, async (c) => { + try { + // Middleware ensures indexingStatus is available and not an Error + // This check is for TypeScript type safety + if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) { + throw new Error("Invariant violation: indexingStatus should be validated by middleware"); } - }, -); + + const { parentNode } = c.req.valid("param"); + const query = c.req.valid("query"); + const { registrarActions, pageContext } = await fetchRegistrarActions(parentNode, query); + + // Get the accurateAsOf timestamp from the slowest chain indexing cursor + const accurateAsOf = c.var.indexingStatus.snapshot.slowestChainIndexingCursor; + + // respond with success response + return c.json( + serializeRegistrarActionsResponse({ + responseCode: RegistrarActionsResponseCodes.Ok, + registrarActions, + pageContext, + accurateAsOf, + } satisfies RegistrarActionsResponseOk), + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Unknown error"; + logger.error(errorMessage); + + // respond with 500 error response + return c.json( + serializeRegistrarActionsResponse({ + responseCode: RegistrarActionsResponseCodes.Error, + error: { + message: `Registrar Actions API Response is unavailable`, + }, + } satisfies RegistrarActionsResponseError), + 500, + ); + } +}); export default app; diff --git a/apps/ensapi/src/handlers/resolution-api.routes.ts b/apps/ensapi/src/handlers/resolution-api.routes.ts new file mode 100644 index 000000000..24d43d4ff --- /dev/null +++ b/apps/ensapi/src/handlers/resolution-api.routes.ts @@ -0,0 +1,104 @@ +import { createRoute } from "@hono/zod-openapi"; +import { z } from "zod/v4"; + +import { + makeResolvePrimaryNameResponseSchema, + makeResolvePrimaryNamesResponseSchema, + makeResolveRecordsResponseSchema, +} from "@ensnode/ensnode-sdk/internal"; + +import { params } from "@/lib/handlers/params.schema"; + +export const basePath = "/api/resolve"; + +export const resolveRecordsGetMeta = createRoute({ + method: "get", + path: "/records/:name", + tags: ["Resolution"], + summary: "Resolve ENS Records", + description: "Resolves ENS records for a given name", + request: { + params: z.object({ name: params.name }), + query: z + .object({ + ...params.selectionParams.shape, + trace: params.trace, + accelerate: params.accelerate, + }) + .transform((value) => { + const { trace, accelerate, ...selectionParams } = value; + const selection = params.selection.parse(selectionParams); + return { selection, trace, accelerate }; + }), + }, + responses: { + 200: { + description: "Successfully resolved records", + content: { + "application/json": { + schema: makeResolveRecordsResponseSchema(), + }, + }, + }, + }, +}); + +export const resolvePrimaryNameGetMeta = createRoute({ + method: "get", + path: "/primary-name/:address/:chainId", + tags: ["Resolution"], + summary: "Resolve Primary Name", + description: "Resolves a primary name for a given `address` and `chainId`", + request: { + params: z.object({ + address: params.address, + chainId: params.defaultableChainId, + }), + query: z.object({ + trace: params.trace, + accelerate: params.accelerate, + }), + }, + responses: { + 200: { + description: "Successfully resolved name", + content: { + "application/json": { + schema: makeResolvePrimaryNameResponseSchema(), + }, + }, + }, + }, +}); + +export const resolvePrimaryNamesGetMeta = createRoute({ + method: "get", + path: "/primary-names/:address", + tags: ["Resolution"], + summary: "Resolve Primary Names", + description: "Resolves all primary names for a given address across multiple chains", + request: { + params: z.object({ address: params.address }), + query: z.object({ + chainIds: params.chainIdsWithoutDefaultChainId, + trace: params.trace, + accelerate: params.accelerate, + }), + }, + responses: { + 200: { + description: "Successfully resolved records", + content: { + "application/json": { + schema: makeResolvePrimaryNamesResponseSchema(), + }, + }, + }, + }, +}); + +export const routes = [ + resolveRecordsGetMeta, + resolvePrimaryNameGetMeta, + resolvePrimaryNamesGetMeta, +]; diff --git a/apps/ensapi/src/handlers/resolution-api.ts b/apps/ensapi/src/handlers/resolution-api.ts index 08c94d0cd..cdab1b074 100644 --- a/apps/ensapi/src/handlers/resolution-api.ts +++ b/apps/ensapi/src/handlers/resolution-api.ts @@ -1,21 +1,11 @@ -import { describeRoute, resolver as validationResolver } from "hono-openapi"; -import { z } from "zod/v4"; - import type { Duration, ResolvePrimaryNameResponse, ResolvePrimaryNamesResponse, ResolveRecordsResponse, } from "@ensnode/ensnode-sdk"; -import { - makeResolvePrimaryNameResponseSchema, - makeResolvePrimaryNamesResponseSchema, - makeResolveRecordsResponseSchema, -} from "@ensnode/ensnode-sdk/internal"; - -import { params } from "@/lib/handlers/params.schema"; -import { validate } from "@/lib/handlers/validate"; -import { factory } from "@/lib/hono-factory"; + +import { createApp } from "@/lib/hono-factory"; import { resolveForward } from "@/lib/resolution/forward-resolution"; import { resolvePrimaryNames } from "@/lib/resolution/multichain-primary-name-resolution"; import { resolveReverse } from "@/lib/resolution/reverse-resolution"; @@ -23,13 +13,19 @@ import { runWithTrace } from "@/lib/tracing/tracing-api"; import { canAccelerateMiddleware } from "@/middleware/can-accelerate.middleware"; import { makeIsRealtimeMiddleware } from "@/middleware/is-realtime.middleware"; +import { + resolvePrimaryNameGetMeta, + resolvePrimaryNamesGetMeta, + resolveRecordsGetMeta, +} from "./resolution-api.routes"; + /** * The effective distance for acceleration is indexing status cache time plus * MAX_REALTIME_DISTANCE_TO_ACCELERATE. */ const MAX_REALTIME_DISTANCE_TO_ACCELERATE: Duration = 60; // 1 minute in seconds -const app = factory.createApp(); +const app = createApp(); // inject c.var.isRealtime derived from MAX_REALTIME_DISTANCE_TO_ACCELERATE app.use(makeIsRealtimeMiddleware("resolution-api", MAX_REALTIME_DISTANCE_TO_ACCELERATE)); @@ -48,63 +44,30 @@ app.use(canAccelerateMiddleware); * 3. Combined resolution: * GET /records/example.eth&name=true&addresses=60,0&texts=avatar,com.twitter */ -app.get( - "/records/:name", - describeRoute({ - tags: ["Resolution"], - summary: "Resolve ENS Records", - description: "Resolves ENS records for a given name", - responses: { - 200: { - description: "Successfully resolved records", - content: { - "application/json": { - schema: validationResolver(makeResolveRecordsResponseSchema()), - }, - }, - }, - }, - }), - validate("param", z.object({ name: params.name })), - validate( - "query", - z - .object({ - ...params.selectionParams.shape, - trace: params.trace, - accelerate: params.accelerate, - }) - .transform((value) => { - const { trace, accelerate, ...selectionParams } = value; - const selection = params.selection.parse(selectionParams); - return { selection, trace, accelerate }; - }), - ), - async (c) => { - // context must be set by the required middleware - if (c.var.canAccelerate === undefined) { - throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`); - } - - const { name } = c.req.valid("param"); - const { selection, trace: showTrace, accelerate } = c.req.valid("query"); - const canAccelerate = c.var.canAccelerate; - - const { result, trace } = await runWithTrace(() => - resolveForward(name, selection, { accelerate, canAccelerate }), - ); - - const response = { - records: result, - - accelerationRequested: accelerate, - accelerationAttempted: accelerate && canAccelerate, - ...(showTrace && { trace }), - } satisfies ResolveRecordsResponse; - - return c.json(response); - }, -); +app.openapi(resolveRecordsGetMeta, async (c) => { + // context must be set by the required middleware + if (c.var.canAccelerate === undefined) { + throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`); + } + + const { name } = c.req.valid("param"); + const { selection, trace: showTrace, accelerate } = c.req.valid("query"); + const canAccelerate = c.var.canAccelerate; + + const { result, trace } = await runWithTrace(() => + resolveForward(name, selection, { accelerate, canAccelerate }), + ); + + const response = { + records: result, + + accelerationRequested: accelerate, + accelerationAttempted: accelerate && canAccelerate, + ...(showTrace && { trace }), + } satisfies ResolveRecordsResponse; + + return c.json(response); +}); /** * Example queries for /primary-name: @@ -118,56 +81,30 @@ app.get( * 3. ENSIP-19 Primary Name (for 'default' EVM Chain) * GET /primary-name/0x1234...abcd/0 */ -app.get( - "/primary-name/:address/:chainId", - describeRoute({ - tags: ["Resolution"], - summary: "Resolve Primary Name", - description: "Resolves a primary name for a given `address` and `chainId`", - responses: { - 200: { - description: "Successfully resolved name", - content: { - "application/json": { - schema: validationResolver(makeResolvePrimaryNameResponseSchema()), - }, - }, - }, - }, - }), - validate("param", z.object({ address: params.address, chainId: params.defaultableChainId })), - validate( - "query", - z.object({ - trace: params.trace, - accelerate: params.accelerate, - }), - ), - async (c) => { - // context must be set by the required middleware - if (c.var.canAccelerate === undefined) { - throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`); - } - - const { address, chainId } = c.req.valid("param"); - const { trace: showTrace, accelerate } = c.req.valid("query"); - const canAccelerate = c.var.canAccelerate; - - const { result, trace } = await runWithTrace(() => - resolveReverse(address, chainId, { accelerate, canAccelerate }), - ); - - const response = { - name: result, - - accelerationRequested: accelerate, - accelerationAttempted: accelerate && canAccelerate, - ...(showTrace && { trace }), - } satisfies ResolvePrimaryNameResponse; - - return c.json(response); - }, -); +app.openapi(resolvePrimaryNameGetMeta, async (c) => { + // context must be set by the required middleware + if (c.var.canAccelerate === undefined) { + throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`); + } + + const { address, chainId } = c.req.valid("param"); + const { trace: showTrace, accelerate } = c.req.valid("query"); + const canAccelerate = c.var.canAccelerate; + + const { result, trace } = await runWithTrace(() => + resolveReverse(address, chainId, { accelerate, canAccelerate }), + ); + + const response = { + name: result, + + accelerationRequested: accelerate, + accelerationAttempted: accelerate && canAccelerate, + ...(showTrace && { trace }), + } satisfies ResolvePrimaryNameResponse; + + return c.json(response); +}); /** * Example queries for /primary-names: @@ -178,56 +115,29 @@ app.get( * 2. Multichain ENSIP-19 Primary Names Lookup (specific chain ids) * GET /primary-names/0x1234...abcd?chainIds=1,10,8453 */ -app.get( - "/primary-names/:address", - describeRoute({ - tags: ["Resolution"], - summary: "Resolve Primary Names", - description: "Resolves all primary names for a given address across multiple chains", - responses: { - 200: { - description: "Successfully resolved records", - content: { - "application/json": { - schema: validationResolver(makeResolvePrimaryNamesResponseSchema()), - }, - }, - }, - }, - }), - validate("param", z.object({ address: params.address })), - validate( - "query", - z.object({ - chainIds: params.chainIdsWithoutDefaultChainId, - trace: params.trace, - accelerate: params.accelerate, - }), - ), - async (c) => { - // context must be set by the required middleware - if (c.var.canAccelerate === undefined) { - throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`); - } - - const { address } = c.req.valid("param"); - const { chainIds, trace: showTrace, accelerate } = c.req.valid("query"); - const canAccelerate = c.var.canAccelerate; - - const { result, trace } = await runWithTrace(() => - resolvePrimaryNames(address, chainIds, { accelerate, canAccelerate }), - ); - - const response = { - names: result, - - accelerationRequested: accelerate, - accelerationAttempted: accelerate && canAccelerate, - ...(showTrace && { trace }), - } satisfies ResolvePrimaryNamesResponse; - - return c.json(response); - }, -); +app.openapi(resolvePrimaryNamesGetMeta, async (c) => { + // context must be set by the required middleware + if (c.var.canAccelerate === undefined) { + throw new Error(`Invariant(resolution-api): canAccelerateMiddleware required`); + } + + const { address } = c.req.valid("param"); + const { chainIds, trace: showTrace, accelerate } = c.req.valid("query"); + const canAccelerate = c.var.canAccelerate; + + const { result, trace } = await runWithTrace(() => + resolvePrimaryNames(address, chainIds, { accelerate, canAccelerate }), + ); + + const response = { + names: result, + + accelerationRequested: accelerate, + accelerationAttempted: accelerate && canAccelerate, + ...(showTrace && { trace }), + } satisfies ResolvePrimaryNamesResponse; + + return c.json(response); +}); export default app; diff --git a/apps/ensapi/src/handlers/subgraph-api.ts b/apps/ensapi/src/handlers/subgraph-api.ts index d426d5963..41e885c9a 100644 --- a/apps/ensapi/src/handlers/subgraph-api.ts +++ b/apps/ensapi/src/handlers/subgraph-api.ts @@ -6,7 +6,7 @@ import * as schema from "@ensnode/ensnode-schema"; import type { Duration } from "@ensnode/ensnode-sdk"; import { subgraphGraphQLMiddleware } from "@ensnode/ponder-subgraph"; -import { factory } from "@/lib/hono-factory"; +import { createApp } from "@/lib/hono-factory"; import { makeSubgraphApiDocumentation } from "@/lib/subgraph/api-documentation"; import { filterSchemaByPrefix } from "@/lib/subgraph/filter-schema-by-prefix"; import { fixContentLengthMiddleware } from "@/middleware/fix-content-length.middleware"; @@ -20,7 +20,7 @@ const MAX_REALTIME_DISTANCE_TO_RESOLVE: Duration = 10 * 60; // 10 minutes in sec // generate a subgraph-specific subset of the schema const subgraphSchema = filterSchemaByPrefix("subgraph_", schema); -const app = factory.createApp(); +const app = createApp(); // 404 if subgraph core plugin not enabled app.use(requireCorePluginMiddleware("subgraph")); @@ -35,7 +35,11 @@ app.use(thegraphFallbackMiddleware); app.use(fixContentLengthMiddleware); // inject api documentation into graphql introspection requests -app.use(createDocumentationMiddleware(makeSubgraphApiDocumentation(), { path: "/subgraph" })); +app.use( + createDocumentationMiddleware(makeSubgraphApiDocumentation(), { + path: "/subgraph", + }), +); // inject _meta into the hono (and yoga) context for the subgraph middleware app.use(subgraphMetaMiddleware); diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 9483c4f6a..07cb9804d 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -1,131 +1,20 @@ -import packageJson from "@/../package.json" with { type: "json" }; +/** + * Server entrypoint: starts the HTTP server, manages caches, and handles + * graceful shutdown. The app itself (routes, middleware) is defined in app.ts. + */ + import config from "@/config"; import { serve } from "@hono/node-server"; -import { otel } from "@hono/otel"; -import { cors } from "hono/cors"; -import { html } from "hono/html"; -import { openAPIRouteHandler } from "hono-openapi"; +import app from "@/app"; import { indexingStatusCache } from "@/cache/indexing-status.cache"; import { getReferralLeaderboardEditionsCaches } from "@/cache/referral-leaderboard-editions.cache"; import { referralProgramEditionConfigSetCache } from "@/cache/referral-program-edition-set.cache"; import { referrerLeaderboardCache } from "@/cache/referrer-leaderboard.cache"; import { redactEnsApiConfig } from "@/config/redact"; -import { errorResponse } from "@/lib/handlers/error-response"; -import { factory } from "@/lib/hono-factory"; import { sdk } from "@/lib/instrumentation"; import logger from "@/lib/logger"; -import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; - -import amIRealtimeApi from "./handlers/amirealtime-api"; -import ensanalyticsApi from "./handlers/ensanalytics-api"; -import ensanalyticsApiV1 from "./handlers/ensanalytics-api-v1"; -import ensNodeApi from "./handlers/ensnode-api"; -import subgraphApi from "./handlers/subgraph-api"; - -const app = factory.createApp(); - -// set the X-ENSNode-Version header to the current version -app.use(async (ctx, next) => { - ctx.header("x-ensnode-version", packageJson.version); - return next(); -}); - -// use CORS middleware -app.use(cors({ origin: "*" })); - -// include automatic OpenTelemetry instrumentation for incoming requests -app.use(otel()); - -// add ENSIndexer Indexing Status Middleware to all routes for convenience -app.use(indexingStatusMiddleware); - -// host welcome page -app.get("/", (c) => - c.html(html` - - - - - - ENSApi - - -

Hello, World!

-

You've reached the root of an ENSApi instance. You might be looking for the ENSNode documentation.

- - -`), -); - -// use ENSNode HTTP API at /api -app.route("/api", ensNodeApi); - -// use Subgraph GraphQL API at /subgraph -app.route("/subgraph", subgraphApi); - -// use ENSAnalytics API at /ensanalytics (v0, implicit) -app.route("/ensanalytics", ensanalyticsApi); - -// use ENSAnalytics API v1 at /v1/ensanalytics -app.route("/v1/ensanalytics", ensanalyticsApiV1); - -// use Am I Realtime API at /amirealtime -app.route("/amirealtime", amIRealtimeApi); - -// use OpenAPI Schema -app.get( - "/openapi.json", - openAPIRouteHandler(app, { - documentation: { - info: { - title: "ENSApi APIs", - version: packageJson.version, - description: - "APIs for ENS resolution, navigating the ENS nameforest, and metadata about an ENSNode", - }, - servers: [ - { url: "https://api.alpha.ensnode.io", description: "ENSNode Alpha (Mainnet)" }, - { - url: "https://api.alpha-sepolia.ensnode.io", - description: "ENSNode Alpha (Sepolia Testnet)", - }, - { url: `http://localhost:${config.port}`, description: "Local Development" }, - ], - tags: [ - { - name: "Resolution", - description: "APIs for resolving ENS names and addresses", - }, - { - name: "Meta", - description: "APIs for indexing status, configuration, and realtime monitoring", - }, - { - name: "Explore", - description: - "APIs for exploring the indexed state of ENS, including name tokens and registrar actions", - }, - { - name: "ENSAwards", - description: "APIs for ENSAwards functionality, including referrer data", - }, - ], - }, - }), -); - -// will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware -app.get("/health", async (c) => { - return c.json({ message: "fallback ok" }); -}); - -// log hono errors to console -app.onError((error, ctx) => { - logger.error(error); - return errorResponse(ctx, "Internal Server Error"); -}); // start ENSNode API OpenTelemetry SDK sdk.start(); diff --git a/apps/ensapi/src/lib/handlers/validate.ts b/apps/ensapi/src/lib/handlers/validate.ts deleted file mode 100644 index 4316e07bb..000000000 --- a/apps/ensapi/src/lib/handlers/validate.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { SchemaError } from "@standard-schema/utils"; -import type { ValidationTargets } from "hono"; -import { validator } from "hono-openapi"; -import type { ZodType } from "zod/v4"; - -import { errorResponse } from "./error-response"; - -/** - * Creates a Hono validation middleware with custom error formatting. - * - * Wraps the Hono validator with custom error handling that uses the - * errorResponse function for consistent error formatting across the API. - * - * @param target - The validation target (param, query, json, etc.) - * @param schema - The Zod schema to validate against - * @returns Hono middleware that validates the specified target - */ -export const validate = ( - target: Target, - schema: T, -) => - validator(target, schema, (result, c) => { - // if validation failed, return our custom-formatted ErrorResponse instead of default - if (!result.success) { - // Wrap the Standard Schema issues in a SchemaError instance - // for consistent error handling in errorResponse - return errorResponse(c, new SchemaError(result.error)); - } - }); diff --git a/apps/ensapi/src/lib/hono-factory.ts b/apps/ensapi/src/lib/hono-factory.ts index 2a4f47914..b37b82735 100644 --- a/apps/ensapi/src/lib/hono-factory.ts +++ b/apps/ensapi/src/lib/hono-factory.ts @@ -1,5 +1,7 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; import { createFactory } from "hono/factory"; +import { errorResponse } from "@/lib/handlers/error-response"; import type { CanAccelerateMiddlewareVariables } from "@/middleware/can-accelerate.middleware"; import type { IndexingStatusMiddlewareVariables } from "@/middleware/indexing-status.middleware"; import type { IsRealtimeMiddlewareVariables } from "@/middleware/is-realtime.middleware"; @@ -14,6 +16,17 @@ export type MiddlewareVariables = IndexingStatusMiddlewareVariables & ReferralProgramEditionConfigSetMiddlewareVariables & ReferralLeaderboardEditionsCachesMiddlewareVariables; -export const factory = createFactory<{ - Variables: Partial; -}>(); +type AppEnv = { Variables: Partial }; + +// factory is kept for createMiddleware usage in middleware files +export const factory = createFactory(); + +export function createApp() { + return new OpenAPIHono({ + defaultHook: (result, c) => { + if (!result.success) { + return errorResponse(c, result.error); + } + }, + }); +} diff --git a/apps/ensapi/src/openapi-routes.ts b/apps/ensapi/src/openapi-routes.ts new file mode 100644 index 000000000..856850ed7 --- /dev/null +++ b/apps/ensapi/src/openapi-routes.ts @@ -0,0 +1,76 @@ +/** + * Lightweight route composition for OpenAPI spec generation. + * + * This module builds a minimal OpenAPIHono app with route definitions + * registered via `app.openapi()` with stub handlers. It imports only + * from `.routes.ts` files which have zero config/env dependencies, + * making it safe to import in contexts where the app config is not + * available (e.g. script-based spec generation). + * + * The runtime server (index.ts) does NOT use this module. It mounts + * the real handler files directly. This module exists solely so that + * `app.getOpenAPI31Document()` can inspect the route metadata. + */ + +import { OpenAPIHono } from "@hono/zod-openapi"; + +import { + basePath as amIRealtimeBasePath, + routes as amIRealtimeRoutes, +} from "./handlers/amirealtime-api.routes"; +import { + basePath as ensanalyticsBasePath, + routes as ensanalyticsRoutes, +} from "./handlers/ensanalytics-api.routes"; +import { + basePath as ensanalyticsV1BasePath, + routes as ensanalyticsV1Routes, +} from "./handlers/ensanalytics-api-v1.routes"; +import { + basePath as ensnodeBasePath, + routes as ensnodeRoutes, +} from "./handlers/ensnode-api.routes"; +import { + basePath as nameTokensBasePath, + routes as nameTokensRoutes, +} from "./handlers/name-tokens-api.routes"; +import { + basePath as registrarActionsBasePath, + routes as registrarActionsRoutes, +} from "./handlers/registrar-actions-api.routes"; +import { + basePath as resolutionBasePath, + routes as resolutionRoutes, +} from "./handlers/resolution-api.routes"; + +type RouteGroup = { + basePath: string; + routes: readonly { method: string; path: string }[]; +}; + +const routeGroups: RouteGroup[] = [ + { basePath: ensnodeBasePath, routes: ensnodeRoutes }, + { basePath: nameTokensBasePath, routes: nameTokensRoutes }, + { basePath: registrarActionsBasePath, routes: registrarActionsRoutes }, + { basePath: resolutionBasePath, routes: resolutionRoutes }, + { basePath: ensanalyticsBasePath, routes: ensanalyticsRoutes }, + { basePath: ensanalyticsV1BasePath, routes: ensanalyticsV1Routes }, + { basePath: amIRealtimeBasePath, routes: amIRealtimeRoutes }, +]; + +// Stub handler for spec generation - never actually called +const stub = (c: any) => c.text(""); + +export function createRoutesForSpec() { + const app = new OpenAPIHono(); + + for (const group of routeGroups) { + for (const route of group.routes) { + const fullPath = route.path === "/" ? group.basePath : `${group.basePath}${route.path}`; + // Override path to include the basePath prefix + app.openapi({ ...route, path: fullPath } as any, stub); + } + } + + return app; +} diff --git a/apps/ensapi/src/openapi.ts b/apps/ensapi/src/openapi.ts new file mode 100644 index 000000000..39a7ad845 --- /dev/null +++ b/apps/ensapi/src/openapi.ts @@ -0,0 +1,38 @@ +export const openapiDocumentation = { + openapi: "3.1.0" as const, + info: { + title: "ENSApi APIs", + version: "0.0.0", // replaced at runtime with package.json version + description: + "APIs for ENS resolution, navigating the ENS nameforest, and metadata about an ENSNode", + }, + servers: [ + { + url: "https://api.alpha.ensnode.io", + description: "ENSNode Alpha (Mainnet)", + }, + { + url: "https://api.alpha-sepolia.ensnode.io", + description: "ENSNode Alpha (Sepolia Testnet)", + }, + ], + tags: [ + { + name: "Resolution", + description: "APIs for resolving ENS names and addresses", + }, + { + name: "Meta", + description: "APIs for indexing status, configuration, and realtime monitoring", + }, + { + name: "Explore", + description: + "APIs for exploring the indexed state of ENS, including name tokens and registrar actions", + }, + { + name: "ENSAwards", + description: "APIs for ENSAwards functionality, including referrer data", + }, + ], +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dd605a4b..7a4b4076e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -324,9 +324,9 @@ importers: '@hono/otel': specifier: ^0.2.2 version: 0.2.2(hono@4.11.7) - '@hono/standard-validator': - specifier: ^0.2.2 - version: 0.2.2(@standard-schema/spec@1.0.0)(hono@4.11.7) + '@hono/zod-openapi': + specifier: 1.2.1 + version: 1.2.1(hono@4.11.7)(zod@4.3.6) '@namehash/ens-referrals': specifier: workspace:* version: link:../../packages/ens-referrals @@ -396,9 +396,6 @@ importers: hono: specifier: 'catalog:' version: 4.11.7 - hono-openapi: - specifier: ^1.1.2 - version: 1.1.2(@hono/standard-validator@0.2.2(@standard-schema/spec@1.0.0)(hono@4.11.7))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.0.0)(arktype@2.1.29)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(hono@4.11.7)(openapi-types@12.1.3) p-memoize: specifier: ^8.0.0 version: 8.0.0 @@ -1144,12 +1141,6 @@ packages: '@antfu/utils@9.3.0': resolution: {integrity: sha512-9hFT4RauhcUzqOE4f1+frMKLZrgNog5b06I7VmZQV1BkvwvqrbC8EBZf3L1eEL2AKb6rNKjER0sEvJiSP1FXEA==} - '@ark/schema@0.56.0': - resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==} - - '@ark/util@0.56.0': - resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==} - '@asamuzakjp/css-color@4.0.5': resolution: {integrity: sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==} @@ -1159,6 +1150,11 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@asteasolutions/zod-to-openapi@8.4.1': + resolution: {integrity: sha512-WmJUsFINbnWxGvHSd16aOjgKf+5GsfdxruO2YDLcgplsidakCauik1lhlk83YDH06265Yd1XtUyF24o09uygpw==} + peerDependencies: + zod: ^4.0.0 + '@astrojs/check@0.5.10': resolution: {integrity: sha512-vliHXM9cu/viGeKiksUM4mXfO816ohWtawTl2ADPgTsd4nUMjFiyAl7xFZhF34yy4hq4qf7jvK1F2PlR3b5I5w==} hasBin: true @@ -2224,11 +2220,18 @@ packages: peerDependencies: hono: '*' - '@hono/standard-validator@0.2.2': - resolution: {integrity: sha512-mJ7W84Bt/rSvoIl63Ynew+UZOHAzzRAoAXb3JaWuxAkM/Lzg+ZHTCUiz77KOtn2e623WNN8LkD57Dk0szqUrIw==} + '@hono/zod-openapi@1.2.1': + resolution: {integrity: sha512-aZza4V8wkqpdHBWFNPiCeWd0cGOXbYuQW9AyezHs/jwQm5p67GkUyXwfthAooAwnG7thTpvOJkThZpCoY6us8w==} + engines: {node: '>=16.0.0'} + peerDependencies: + hono: '>=4.3.6' + zod: ^4.0.0 + + '@hono/zod-validator@0.7.6': + resolution: {integrity: sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==} peerDependencies: - '@standard-schema/spec': ^1.0.0 hono: '>=3.9.0' + zod: ^3.25.0 || ^4.0.0 '@iconify-json/lucide@1.2.71': resolution: {integrity: sha512-KL+3JHW+wN8QqT3CN+7e1SzTe+gIunFBuUICtVmdCmdVRx+MdGNkX4xJhXoYHfhYO2azrEhoGPG+It9k30aZkw==} @@ -3814,67 +3817,6 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} - '@standard-community/standard-json@0.3.5': - resolution: {integrity: sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA==} - peerDependencies: - '@standard-schema/spec': ^1.0.0 - '@types/json-schema': ^7.0.15 - '@valibot/to-json-schema': ^1.3.0 - arktype: ^2.1.20 - effect: ^3.16.8 - quansync: ^0.2.11 - sury: ^10.0.0 - typebox: ^1.0.17 - valibot: ^1.1.0 - zod: ^3.25.0 || ^4.0.0 - zod-to-json-schema: ^3.24.5 - peerDependenciesMeta: - '@valibot/to-json-schema': - optional: true - arktype: - optional: true - effect: - optional: true - sury: - optional: true - typebox: - optional: true - valibot: - optional: true - zod: - optional: true - zod-to-json-schema: - optional: true - - '@standard-community/standard-openapi@0.2.9': - resolution: {integrity: sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg==} - peerDependencies: - '@standard-community/standard-json': ^0.3.5 - '@standard-schema/spec': ^1.0.0 - arktype: ^2.1.20 - effect: ^3.17.14 - openapi-types: ^12.1.3 - sury: ^10.0.0 - typebox: ^1.0.0 - valibot: ^1.1.0 - zod: ^3.25.0 || ^4.0.0 - zod-openapi: ^4 - peerDependenciesMeta: - arktype: - optional: true - effect: - optional: true - sury: - optional: true - typebox: - optional: true - valibot: - optional: true - zod: - optional: true - zod-openapi: - optional: true - '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -4258,9 +4200,6 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -4631,12 +4570,6 @@ packages: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - arkregex@0.0.5: - resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==} - - arktype@2.1.29: - resolution: {integrity: sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ==} - array-iterate@2.0.1: resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} @@ -6042,21 +5975,6 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hono-openapi@1.1.2: - resolution: {integrity: sha512-toUcO60MftRBxqcVyxsHNYs2m4vf4xkQaiARAucQx3TiBPDtMNNkoh+C4I1vAretQZiGyaLOZNWn1YxfSyUA5g==} - peerDependencies: - '@hono/standard-validator': ^0.2.0 - '@standard-community/standard-json': ^0.3.5 - '@standard-community/standard-openapi': ^0.2.9 - '@types/json-schema': ^7.0.15 - hono: ^4.8.3 - openapi-types: ^12.1.3 - peerDependenciesMeta: - '@hono/standard-validator': - optional: true - hono: - optional: true - hono@4.11.7: resolution: {integrity: sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==} engines: {node: '>=16.9.0'} @@ -6983,8 +6901,8 @@ packages: oniguruma-to-es@4.3.3: resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} - openapi-types@12.1.3: - resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openapi3-ts@4.5.0: + resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==} outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -8944,14 +8862,6 @@ snapshots: '@antfu/utils@9.3.0': {} - '@ark/schema@0.56.0': - dependencies: - '@ark/util': 0.56.0 - optional: true - - '@ark/util@0.56.0': - optional: true - '@asamuzakjp/css-color@4.0.5': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -8970,6 +8880,11 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@asteasolutions/zod-to-openapi@8.4.1(zod@4.3.6)': + dependencies: + openapi3-ts: 4.5.0 + zod: 4.3.6 + '@astrojs/check@0.5.10(prettier@3.6.2)(typescript@5.9.3)': dependencies: '@astrojs/language-server': 2.15.5(prettier@3.6.2)(typescript@5.9.3) @@ -10540,10 +10455,18 @@ snapshots: '@opentelemetry/semantic-conventions': 1.37.0 hono: 4.11.7 - '@hono/standard-validator@0.2.2(@standard-schema/spec@1.0.0)(hono@4.11.7)': + '@hono/zod-openapi@1.2.1(hono@4.11.7)(zod@4.3.6)': + dependencies: + '@asteasolutions/zod-to-openapi': 8.4.1(zod@4.3.6) + '@hono/zod-validator': 0.7.6(hono@4.11.7)(zod@4.3.6) + hono: 4.11.7 + openapi3-ts: 4.5.0 + zod: 4.3.6 + + '@hono/zod-validator@0.7.6(hono@4.11.7)(zod@4.3.6)': dependencies: - '@standard-schema/spec': 1.0.0 hono: 4.11.7 + zod: 4.3.6 '@iconify-json/lucide@1.2.71': dependencies: @@ -12347,25 +12270,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6)': - dependencies: - '@standard-schema/spec': 1.0.0 - '@types/json-schema': 7.0.15 - quansync: 0.2.11 - optionalDependencies: - arktype: 2.1.29 - zod: 4.3.6 - zod-to-json-schema: 3.24.6(zod@4.3.6) - - '@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.0.0)(arktype@2.1.29)(openapi-types@12.1.3)(zod@4.3.6)': - dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6) - '@standard-schema/spec': 1.0.0 - openapi-types: 12.1.3 - optionalDependencies: - arktype: 2.1.29 - zod: 4.3.6 - '@standard-schema/spec@1.0.0': {} '@standard-schema/utils@0.3.0': {} @@ -12746,8 +12650,6 @@ snapshots: '@types/js-yaml@4.0.9': {} - '@types/json-schema@7.0.15': {} - '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -13163,18 +13065,6 @@ snapshots: aria-query@5.3.2: {} - arkregex@0.0.5: - dependencies: - '@ark/util': 0.56.0 - optional: true - - arktype@2.1.29: - dependencies: - '@ark/schema': 0.56.0 - '@ark/util': 0.56.0 - arkregex: 0.0.5 - optional: true - array-iterate@2.0.1: {} array-union@2.1.0: {} @@ -14836,16 +14726,6 @@ snapshots: help-me@5.0.0: {} - hono-openapi@1.1.2(@hono/standard-validator@0.2.2(@standard-schema/spec@1.0.0)(hono@4.11.7))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.0.0)(arktype@2.1.29)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(hono@4.11.7)(openapi-types@12.1.3): - dependencies: - '@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6) - '@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.0.0)(@types/json-schema@7.0.15)(arktype@2.1.29)(quansync@0.2.11)(zod-to-json-schema@3.24.6(zod@4.3.6))(zod@4.3.6))(@standard-schema/spec@1.0.0)(arktype@2.1.29)(openapi-types@12.1.3)(zod@4.3.6) - '@types/json-schema': 7.0.15 - openapi-types: 12.1.3 - optionalDependencies: - '@hono/standard-validator': 0.2.2(@standard-schema/spec@1.0.0)(hono@4.11.7) - hono: 4.11.7 - hono@4.11.7: {} html-encoding-sniffer@4.0.0: @@ -15959,7 +15839,9 @@ snapshots: regex: 6.0.1 regex-recursion: 6.0.2 - openapi-types@12.1.3: {} + openapi3-ts@4.5.0: + dependencies: + yaml: 2.8.1 outdent@0.5.0: {} @@ -18137,11 +18019,6 @@ snapshots: dependencies: zod: 3.25.76 - zod-to-json-schema@3.24.6(zod@4.3.6): - dependencies: - zod: 4.3.6 - optional: true - zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): dependencies: typescript: 5.9.3