From e80b9f42968293c46f820beef81178ab96165387 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 18 Jan 2026 17:29:59 +0100 Subject: [PATCH 01/23] Implement operation result pattern in ENSApi error handlers --- apps/ensapi/src/index.ts | 23 ++- .../ensapi/src/lib/handlers/error-response.ts | 50 ------- apps/ensapi/src/lib/handlers/validate.ts | 14 +- .../result/result-into-http-response.test.ts | 138 ++++++++++++++++++ .../lib/result/result-into-http-response.ts | 49 +++++++ .../src/shared/result/result-code.ts | 6 + .../src/shared/result/result-common.ts | 61 +++++++- 7 files changed, 280 insertions(+), 61 deletions(-) delete mode 100644 apps/ensapi/src/lib/handlers/error-response.ts create mode 100644 apps/ensapi/src/lib/result/result-into-http-response.test.ts create mode 100644 apps/ensapi/src/lib/result/result-into-http-response.ts diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index 498d77811..c2bdf97d2 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -7,13 +7,19 @@ import { cors } from "hono/cors"; import { html } from "hono/html"; import { openAPIRouteHandler } from "hono-openapi"; +import { + buildResultInternalServerError, + buildResultNotFound, + buildResultOk, +} from "@ensnode/ensnode-sdk"; + import { indexingStatusCache } from "@/cache/indexing-status.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 { resultIntoHttpResponse } from "@/lib/result/result-into-http-response"; import { indexingStatusMiddleware } from "@/middleware/indexing-status.middleware"; import amIRealtimeApi from "./handlers/amirealtime-api"; @@ -112,13 +118,24 @@ app.get( // will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware app.get("/health", async (c) => { - return c.json({ message: "fallback ok" }); + const result = buildResultOk({ message: "fallback ok" }); + + return resultIntoHttpResponse(c, result); +}); + +app.notFound((c) => { + const result = buildResultNotFound("Resource not found"); + + return resultIntoHttpResponse(c, result); }); // log hono errors to console app.onError((error, ctx) => { logger.error(error); - return errorResponse(ctx, "Internal Server Error"); + + const result = buildResultInternalServerError("Internal Server Error"); + + return resultIntoHttpResponse(ctx, result); }); // start ENSNode API OpenTelemetry SDK diff --git a/apps/ensapi/src/lib/handlers/error-response.ts b/apps/ensapi/src/lib/handlers/error-response.ts deleted file mode 100644 index d1f489b04..000000000 --- a/apps/ensapi/src/lib/handlers/error-response.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { SchemaError } from "@standard-schema/utils"; -import type { Context } from "hono"; -import type { ClientErrorStatusCode, ServerErrorStatusCode } from "hono/utils/http-status"; -import { treeifyError, ZodError } from "zod/v4"; - -import type { ErrorResponse } from "@ensnode/ensnode-sdk"; - -/** - * Creates a standardized error response for the ENSApi. - * - * Handles different types of errors and converts them to appropriate HTTP responses - * with consistent error formatting. ZodErrors and Standard Schema validation errors - * return 400 status codes with validation details, while other errors return 500 - * status codes. - * - * @param c - Hono context object - * @param input - The error input (ZodError, SchemaError, Error, string, or unknown) - * @returns JSON error response with appropriate HTTP status code - */ -export const errorResponse = ( - c: Context, - input: ZodError | SchemaError | Error | string | unknown, - statusCode: ClientErrorStatusCode | ServerErrorStatusCode = 500, -) => { - if (input instanceof ZodError) { - return c.json( - { message: "Invalid Input", details: treeifyError(input) } satisfies ErrorResponse, - 400, - ); - } - - if (input instanceof SchemaError) { - // Convert Standard Schema issues to ZodError for consistent formatting - const zodError = new ZodError(input.issues as ZodError["issues"]); - return c.json( - { message: "Invalid Input", details: treeifyError(zodError) } satisfies ErrorResponse, - 400, - ); - } - - if (input instanceof Error) { - return c.json({ message: input.message } satisfies ErrorResponse, statusCode); - } - - if (typeof input === "string") { - return c.json({ message: input } satisfies ErrorResponse, statusCode); - } - - return c.json({ message: "Internal Server Error" } satisfies ErrorResponse, statusCode); -}; diff --git a/apps/ensapi/src/lib/handlers/validate.ts b/apps/ensapi/src/lib/handlers/validate.ts index 4316e07bb..f928ceaee 100644 --- a/apps/ensapi/src/lib/handlers/validate.ts +++ b/apps/ensapi/src/lib/handlers/validate.ts @@ -1,9 +1,11 @@ import { SchemaError } from "@standard-schema/utils"; import type { ValidationTargets } from "hono"; import { validator } from "hono-openapi"; -import type { ZodType } from "zod/v4"; +import { prettifyError, ZodError, type ZodType } from "zod/v4"; -import { errorResponse } from "./error-response"; +import { buildResultInvalidRequest } from "@ensnode/ensnode-sdk"; + +import { resultIntoHttpResponse } from "@/lib/result/result-into-http-response"; /** * Creates a Hono validation middleware with custom error formatting. @@ -22,8 +24,10 @@ export const validate = { // 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)); + // Convert Standard Schema issues to ZodError for consistent formatting + const schemaError = new SchemaError(result.error); + const zodError = new ZodError(schemaError.issues as ZodError["issues"]); + + return resultIntoHttpResponse(c, buildResultInvalidRequest(prettifyError(zodError))); } }); diff --git a/apps/ensapi/src/lib/result/result-into-http-response.test.ts b/apps/ensapi/src/lib/result/result-into-http-response.test.ts new file mode 100644 index 000000000..5b0b5a4ab --- /dev/null +++ b/apps/ensapi/src/lib/result/result-into-http-response.test.ts @@ -0,0 +1,138 @@ +import type { Context } from "hono"; +import { describe, expect, it, vi } from "vitest"; + +import { + buildResultInternalServerError, + buildResultInvalidRequest, + buildResultNotFound, + buildResultOk, + buildResultServiceUnavailable, + ResultCodes, +} from "@ensnode/ensnode-sdk"; + +import { + type OpResultServer, + resultCodeToHttpStatusCode, + resultIntoHttpResponse, +} from "./result-into-http-response"; + +describe("resultCodeToHttpStatusCode", () => { + it("should return 200 for ResultCodes.Ok", () => { + const statusCode = resultCodeToHttpStatusCode(ResultCodes.Ok); + + expect(statusCode).toBe(200); + }); + + it("should return 400 for ResultCodes.InvalidRequest", () => { + const statusCode = resultCodeToHttpStatusCode(ResultCodes.InvalidRequest); + + expect(statusCode).toBe(400); + }); + + it("should return 404 for ResultCodes.NotFound", () => { + const statusCode = resultCodeToHttpStatusCode(ResultCodes.NotFound); + + expect(statusCode).toBe(404); + }); + + it("should return 500 for ResultCodes.InternalServerError", () => { + const statusCode = resultCodeToHttpStatusCode(ResultCodes.InternalServerError); + + expect(statusCode).toBe(500); + }); + + it("should return 503 for ResultCodes.ServiceUnavailable", () => { + const statusCode = resultCodeToHttpStatusCode(ResultCodes.ServiceUnavailable); + + expect(statusCode).toBe(503); + }); +}); + +describe("resultIntoHttpResponse", () => { + it("should return HTTP response with status 200 for Ok result", () => { + const mockResponse = { status: 200, body: "test" }; + const mockContext = { + json: vi.fn().mockReturnValue(mockResponse), + } as unknown as Context; + + const result: OpResultServer = { + resultCode: ResultCodes.Ok, + data: "test data", + }; + + const response = resultIntoHttpResponse(mockContext, result); + + expect(mockContext.json).toHaveBeenCalledWith(result, 200); + expect(response).toBe(mockResponse); + }); + + it("should return HTTP response with status 400 for InvalidRequest result", () => { + const mockResponse = { status: 400, body: "error" }; + const mockContext = { + json: vi.fn().mockReturnValue(mockResponse), + } as unknown as Context; + + const result = buildResultInvalidRequest("Invalid request"); + + const response = resultIntoHttpResponse(mockContext, result); + + expect(mockContext.json).toHaveBeenCalledWith(result, 400); + expect(response).toBe(mockResponse); + }); + + it("should return HTTP response with status 404 for NotFound result", () => { + const mockResponse = { status: 404, body: "not found" }; + const mockContext = { + json: vi.fn().mockReturnValue(mockResponse), + } as unknown as Context; + + const result = buildResultNotFound("Resource not found"); + + const response = resultIntoHttpResponse(mockContext, result); + + expect(mockContext.json).toHaveBeenCalledWith(result, 404); + expect(response).toBe(mockResponse); + }); + + it("should return HTTP response with status 500 for InternalServerError result", () => { + const mockResponse = { status: 500, body: "Internal server error" }; + const mockContext = { + json: vi.fn().mockReturnValue(mockResponse), + } as unknown as Context; + + const result = buildResultInternalServerError("Internal server error"); + + const response = resultIntoHttpResponse(mockContext, result); + + expect(mockContext.json).toHaveBeenCalledWith(result, 500); + expect(response).toBe(mockResponse); + }); + + it("should return HTTP response with status 503 for ServiceUnavailable result", () => { + const mockResponse = { status: 503, body: "unavailable" }; + const mockContext = { + json: vi.fn().mockReturnValue(mockResponse), + } as unknown as Context; + + const result = buildResultServiceUnavailable("Service unavailable"); + + const response = resultIntoHttpResponse(mockContext, result); + + expect(mockContext.json).toHaveBeenCalledWith(result, 503); + expect(response).toBe(mockResponse); + }); + + it("should handle result with complex data object", () => { + const mockResponse = { status: 200, body: "complex" }; + const mockContext = { + json: vi.fn().mockReturnValue(mockResponse), + } as unknown as Context; + + const result = buildResultOk({ id: 1, name: "Test" }); + + const response = resultIntoHttpResponse(mockContext, result); + + expect(mockContext.json).toHaveBeenCalledWith(result, 200); + expect(response).toBe(mockResponse); + }); +}); diff --git a/apps/ensapi/src/lib/result/result-into-http-response.ts b/apps/ensapi/src/lib/result/result-into-http-response.ts new file mode 100644 index 000000000..685be0217 --- /dev/null +++ b/apps/ensapi/src/lib/result/result-into-http-response.ts @@ -0,0 +1,49 @@ +import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +import { type AbstractResultOk, ResultCodes, type ResultServerError } from "@ensnode/ensnode-sdk"; + +export type OpResultServerOk = AbstractResultOk; + +export type OpResultServer = OpResultServerOk | ResultServerError; + +export type OpResultServerResultCode = OpResultServer["resultCode"]; + +/** + * Get HTTP status code corresponding to the given operation result code. + * + * @param resultCode - The operation result code + * @returns Corresponding HTTP status code + */ +export function resultCodeToHttpStatusCode( + resultCode: OpResultServerResultCode, +): ContentfulStatusCode { + switch (resultCode) { + case ResultCodes.Ok: + return 200; + case ResultCodes.InvalidRequest: + return 400; + case ResultCodes.NotFound: + return 404; + case ResultCodes.InternalServerError: + return 500; + case ResultCodes.ServiceUnavailable: + return 503; + } +} + +/** + * Get an HTTP response from the given operation result. + * + * @param c - Hono context + * @param result - The operation result + * @returns HTTP response with appropriate status code and JSON body + */ +export function resultIntoHttpResponse( + c: Context, + result: TResult, +): Response { + const statusCode = resultCodeToHttpStatusCode(result.resultCode); + + return c.json(result, statusCode); +} diff --git a/packages/ensnode-sdk/src/shared/result/result-code.ts b/packages/ensnode-sdk/src/shared/result/result-code.ts index 52e376ea9..e100590dc 100644 --- a/packages/ensnode-sdk/src/shared/result/result-code.ts +++ b/packages/ensnode-sdk/src/shared/result/result-code.ts @@ -17,6 +17,11 @@ export const ResultCodes = { */ InternalServerError: "internal-server-error", + /** + * Server error: the operation failed due to the service being unavailable at the time. + */ + ServiceUnavailable: "service-unavailable", + /** * Server error: the requested resource was not found. */ @@ -48,6 +53,7 @@ export const ResultCodes = { */ export const RESULT_CODE_SERVER_ERROR_CODES = [ ResultCodes.InternalServerError, + ResultCodes.ServiceUnavailable, ResultCodes.NotFound, ResultCodes.InvalidRequest, ] as const; diff --git a/packages/ensnode-sdk/src/shared/result/result-common.ts b/packages/ensnode-sdk/src/shared/result/result-common.ts index d13ab55a9..0b819db71 100644 --- a/packages/ensnode-sdk/src/shared/result/result-common.ts +++ b/packages/ensnode-sdk/src/shared/result/result-common.ts @@ -1,9 +1,54 @@ +/** + * Common Result Types and Builders + * + * This module defines common result types and builder functions for + * standardizing operation results across the SDK. It includes types and + * builders for successful results as well as various error scenarios. + */ + +import type { AbstractResultError, AbstractResultOk } from "./result-base"; +import { type ResultCode, ResultCodes } from "./result-code"; + /************************************************************ - * Internal Server Error + * Result OK ************************************************************/ -import type { AbstractResultError } from "./result-base"; -import { type ResultCode, ResultCodes } from "./result-code"; +export type ResultOk = AbstractResultOk; + +/** + * Builds a result object representing a successful operation. + */ +export function buildResultOk(data: TData): ResultOk { + return { + resultCode: ResultCodes.Ok, + data, + }; +} + +/************************************************************ + * Service Unavailable + ************************************************************/ + +export interface ResultServiceUnavailable + extends AbstractResultError {} + +/** + * Builds a result object representing a service unavailable error. + */ +export const buildResultServiceUnavailable = ( + errorMessage?: string, + suggestRetry: boolean = true, +): ResultServiceUnavailable => { + return { + resultCode: ResultCodes.ServiceUnavailable, + errorMessage: errorMessage ?? "The service is currently unavailable.", + suggestRetry, + }; +}; + +/************************************************************ + * Internal Server Error + ************************************************************/ export interface ResultInternalServerError extends AbstractResultError {} @@ -166,6 +211,16 @@ export const isRecognizedResultCodeForOperation = ( return recognizedResultCodesForOperation.includes(resultCode as ResultCode); }; +/************************************************************ + * All common server errors + ************************************************************/ + +export type ResultServerError = + | ResultInvalidRequest + | ResultNotFound + | ResultInternalServerError + | ResultServiceUnavailable; + /************************************************************ * All common client errors ************************************************************/ From 56aa7977b8c9740a5946adf8d6e084d35be23df1 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 18 Jan 2026 17:30:25 +0100 Subject: [PATCH 02/23] Implement operation result pattern for ENSApi /amirealtime endpoint --- .../src/handlers/amirealtime-api.test.ts | 65 +++++++++++-------- apps/ensapi/src/handlers/amirealtime-api.ts | 56 +++++++++++----- 2 files changed, 79 insertions(+), 42 deletions(-) diff --git a/apps/ensapi/src/handlers/amirealtime-api.test.ts b/apps/ensapi/src/handlers/amirealtime-api.test.ts index b49d57b27..58b17b03a 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.test.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + buildResultOk, type CrossChainIndexingStatusSnapshot, createRealtimeIndexingStatusProjection, type UnixTimestamp, @@ -69,9 +70,11 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); - expect(responseJson).toMatchObject({ - maxWorstCaseDistance: 300, - }); + expect(responseJson).toMatchObject( + buildResultOk({ + maxWorstCaseDistance: 300, + }), + ); }); it("should accept valid maxWorstCaseDistance query param (set to `0`)", async () => { @@ -84,9 +87,11 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); - expect(responseJson).toMatchObject({ - maxWorstCaseDistance: 0, - }); + expect(responseJson).toMatchObject( + buildResultOk({ + maxWorstCaseDistance: 0, + }), + ); }); it("should use default maxWorstCaseDistance when unset", async () => { @@ -99,9 +104,11 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); - expect(responseJson).toMatchObject({ - maxWorstCaseDistance: AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE, - }); + expect(responseJson).toMatchObject( + buildResultOk({ + maxWorstCaseDistance: AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE, + }), + ); }); it("should use default maxWorstCaseDistance when not provided", async () => { @@ -114,9 +121,11 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); - expect(responseJson).toMatchObject({ - maxWorstCaseDistance: AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE, - }); + expect(responseJson).toMatchObject( + buildResultOk({ + maxWorstCaseDistance: AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE, + }), + ); }); it("should reject invalid maxWorstCaseDistance (negative number)", async () => { @@ -161,11 +170,13 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); - expect(responseJson).toMatchObject({ - maxWorstCaseDistance: 10, - slowestChainIndexingCursor: 1766123720, - worstCaseDistance: 9, - }); + expect(responseJson).toStrictEqual( + buildResultOk({ + maxWorstCaseDistance: 10, + slowestChainIndexingCursor: 1766123720, + worstCaseDistance: 9, + }), + ); }); it("should return 200 when worstCaseDistance equals maxWorstCaseDistance", async () => { @@ -178,11 +189,13 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); - expect(responseJson).toMatchObject({ - maxWorstCaseDistance: 10, - slowestChainIndexingCursor: 1766123719, - worstCaseDistance: 10, - }); + expect(responseJson).toStrictEqual( + buildResultOk({ + maxWorstCaseDistance: 10, + slowestChainIndexingCursor: 1766123719, + worstCaseDistance: 10, + }), + ); }); it("should return 503 when worstCaseDistance exceeds maxWorstCaseDistance", async () => { @@ -195,8 +208,8 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(503); - expect(responseJson).toHaveProperty("message"); - expect(responseJson.message).toMatch( + expect(responseJson).toHaveProperty("errorMessage"); + expect(responseJson.errorMessage).toMatch( /Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = 11; maxWorstCaseDistance = 10/, ); }); @@ -215,8 +228,8 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(503); - expect(responseJson).toHaveProperty("message"); - expect(responseJson.message).toMatch( + expect(responseJson).toHaveProperty("errorMessage"); + expect(responseJson.errorMessage).toMatch( /Indexing Status has to be resolved successfully before 'maxWorstCaseDistance' can be applied./, ); }); diff --git a/apps/ensapi/src/handlers/amirealtime-api.ts b/apps/ensapi/src/handlers/amirealtime-api.ts index a9a40ab5f..b1d6c6c00 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.ts @@ -2,13 +2,22 @@ import { minutesToSeconds } from "date-fns"; import { describeRoute } from "hono-openapi"; import z from "zod/v4"; -import type { Duration } from "@ensnode/ensnode-sdk"; +import { + buildResultInternalServerError, + buildResultOk, + buildResultServiceUnavailable, + type Duration, + ResultCodes, +} 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 { + resultCodeToHttpStatusCode, + resultIntoHttpResponse, +} from "@/lib/result/result-into-http-response"; const app = factory.createApp(); @@ -25,11 +34,14 @@ app.get( description: "Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime", responses: { - 200: { + [resultCodeToHttpStatusCode(ResultCodes.Ok)]: { description: "Indexing progress is guaranteed to be within the requested distance of realtime", }, - 503: { + [resultCodeToHttpStatusCode(ResultCodes.InternalServerError)]: { + description: "Indexing progress cannot be determined due to an internal server error", + }, + [resultCodeToHttpStatusCode(ResultCodes.ServiceUnavailable)]: { description: "Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable", }, @@ -48,15 +60,22 @@ app.get( async (c) => { // context must be set by the required middleware if (c.var.indexingStatus === undefined) { - throw new Error(`Invariant(amirealtime-api): indexingStatusMiddleware required.`); + return resultIntoHttpResponse( + c, + buildResultInternalServerError( + `Invariant(amirealtime-api): indexingStatusMiddleware required.`, + ), + ); } // return 503 response error with details on prerequisite being unavailable if (c.var.indexingStatus instanceof Error) { - return errorResponse( + return resultIntoHttpResponse( c, - `Invariant(amirealtime-api): Indexing Status has to be resolved successfully before 'maxWorstCaseDistance' can be applied.`, - 503, + // todo: differentiate between 500 vs. 503 based on error type + buildResultServiceUnavailable( + `Invariant(amirealtime-api): Indexing Status has to be resolved successfully before 'maxWorstCaseDistance' can be applied.`, + ), ); } @@ -67,20 +86,25 @@ app.get( // return 503 response error with details on // requested `maxWorstCaseDistance` vs. actual `worstCaseDistance` if (worstCaseDistance > maxWorstCaseDistance) { - return errorResponse( + // todo: differentiate between 500 vs. 503 based on error type + return resultIntoHttpResponse( c, - `Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = ${worstCaseDistance}; maxWorstCaseDistance = ${maxWorstCaseDistance}`, - 503, + buildResultServiceUnavailable( + `Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = ${worstCaseDistance}; maxWorstCaseDistance = ${maxWorstCaseDistance}`, + ), ); } // return 200 response OK with current details on `maxWorstCaseDistance`, // `slowestChainIndexingCursor`, and `worstCaseDistance` - return c.json({ - maxWorstCaseDistance, - slowestChainIndexingCursor, - worstCaseDistance, - }); + return resultIntoHttpResponse( + c, + buildResultOk({ + maxWorstCaseDistance, + slowestChainIndexingCursor, + worstCaseDistance, + }), + ); }, ); From 902d556949377ad8d74ecdea4a941097485c98c6 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Sun, 18 Jan 2026 19:50:18 +0100 Subject: [PATCH 03/23] Implement operation result pattern for Registrar Actions API --- .../src/handlers/registrar-actions-api.ts | 92 +++++++++---------- .../result/result-into-http-response.test.ts | 11 +-- .../lib/result/result-into-http-response.ts | 12 +-- .../registrar-actions.middleware.ts | 62 ++++++------- .../src/api/registrar-actions/response.ts | 15 ++- .../src/api/registrar-actions/serialize.ts | 6 ++ .../src/shared/result/result-base.ts | 21 +++++ .../src/shared/result/result-common.ts | 48 +++++++++- 8 files changed, 165 insertions(+), 102 deletions(-) diff --git a/apps/ensapi/src/handlers/registrar-actions-api.ts b/apps/ensapi/src/handlers/registrar-actions-api.ts index f854195a1..3d392770a 100644 --- a/apps/ensapi/src/handlers/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/registrar-actions-api.ts @@ -3,16 +3,16 @@ import z from "zod/v4"; import { buildPageContext, + buildResultOkTimestamped, + buildResultServiceUnavailable, type Node, RECORDS_PER_PAGE_DEFAULT, RECORDS_PER_PAGE_MAX, type RegistrarActionsFilter, RegistrarActionsOrders, - RegistrarActionsResponseCodes, - type RegistrarActionsResponseError, - type RegistrarActionsResponseOk, + ResultCodes, registrarActionsFilter, - serializeRegistrarActionsResponse, + serializeNamedRegistrarActions, } from "@ensnode/ensnode-sdk"; import { makeLowercaseAddressSchema, @@ -26,6 +26,10 @@ import { validate } from "@/lib/handlers/validate"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { findRegistrarActions } from "@/lib/registrar-actions/find-registrar-actions"; +import { + resultCodeToHttpStatusCode, + resultIntoHttpResponse, +} from "@/lib/result/result-into-http-response"; import { registrarActionsApiMiddleware } from "@/middleware/registrar-actions.middleware"; const app = factory.createApp(); @@ -161,45 +165,48 @@ app.get( summary: "Get Registrar Actions", description: "Returns all registrar actions with optional filtering and pagination", responses: { - 200: { + [resultCodeToHttpStatusCode(ResultCodes.Ok)]: { description: "Successfully retrieved registrar actions", }, - 400: { + [resultCodeToHttpStatusCode(ResultCodes.InvalidRequest)]: { description: "Invalid query", }, - 500: { - description: "Internal server error", + [resultCodeToHttpStatusCode(ResultCodes.ServiceUnavailable)]: { + description: "Registrar Actions API is unavailable at the moment", }, }, }), 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 query = c.req.valid("query"); const { registrarActions, pageContext } = await fetchRegistrarActions(undefined, query); - // respond with success response - return c.json( - serializeRegistrarActionsResponse({ - responseCode: RegistrarActionsResponseCodes.Ok, - registrarActions, + // Get the accurateAsOf timestamp from the slowest chain indexing cursor + const accurateAsOf = c.var.indexingStatus.snapshot.slowestChainIndexingCursor; + + const result = buildResultOkTimestamped( + { + registrarActions: serializeNamedRegistrarActions(registrarActions), pageContext, - } satisfies RegistrarActionsResponseOk), + }, + accurateAsOf, ); + + return resultIntoHttpResponse(c, result); } 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, - ); + const result = buildResultServiceUnavailable("Registrar Actions API Response is unavailable"); + + return resultIntoHttpResponse(c, result); } }, ); @@ -244,14 +251,14 @@ app.get( description: "Returns registrar actions filtered by parent node hash with optional additional filtering and pagination", responses: { - 200: { + [resultCodeToHttpStatusCode(ResultCodes.Ok)]: { description: "Successfully retrieved registrar actions", }, - 400: { - description: "Invalid input", + [resultCodeToHttpStatusCode(ResultCodes.InvalidRequest)]: { + description: "Invalid query", }, - 500: { - description: "Internal server error", + [resultCodeToHttpStatusCode(ResultCodes.ServiceUnavailable)]: { + description: "Registrar Actions API is unavailable at the moment", }, }, }), @@ -279,29 +286,22 @@ app.get( // 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, + const result = buildResultOkTimestamped( + { + registrarActions: serializeNamedRegistrarActions(registrarActions), pageContext, - accurateAsOf, - } satisfies RegistrarActionsResponseOk), + }, + accurateAsOf, ); + + return resultIntoHttpResponse(c, result); } 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, - ); + const result = buildResultServiceUnavailable("Registrar Actions API Response is unavailable"); + + return resultIntoHttpResponse(c, result); } }, ); diff --git a/apps/ensapi/src/lib/result/result-into-http-response.test.ts b/apps/ensapi/src/lib/result/result-into-http-response.test.ts index 5b0b5a4ab..de3b261c6 100644 --- a/apps/ensapi/src/lib/result/result-into-http-response.test.ts +++ b/apps/ensapi/src/lib/result/result-into-http-response.test.ts @@ -10,11 +10,7 @@ import { ResultCodes, } from "@ensnode/ensnode-sdk"; -import { - type OpResultServer, - resultCodeToHttpStatusCode, - resultIntoHttpResponse, -} from "./result-into-http-response"; +import { resultCodeToHttpStatusCode, resultIntoHttpResponse } from "./result-into-http-response"; describe("resultCodeToHttpStatusCode", () => { it("should return 200 for ResultCodes.Ok", () => { @@ -55,10 +51,7 @@ describe("resultIntoHttpResponse", () => { json: vi.fn().mockReturnValue(mockResponse), } as unknown as Context; - const result: OpResultServer = { - resultCode: ResultCodes.Ok, - data: "test data", - }; + const result = buildResultOk("test data"); const response = resultIntoHttpResponse(mockContext, result); diff --git a/apps/ensapi/src/lib/result/result-into-http-response.ts b/apps/ensapi/src/lib/result/result-into-http-response.ts index 685be0217..38d23c65b 100644 --- a/apps/ensapi/src/lib/result/result-into-http-response.ts +++ b/apps/ensapi/src/lib/result/result-into-http-response.ts @@ -1,13 +1,11 @@ import type { Context } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; -import { type AbstractResultOk, ResultCodes, type ResultServerError } from "@ensnode/ensnode-sdk"; - -export type OpResultServerOk = AbstractResultOk; - -export type OpResultServer = OpResultServerOk | ResultServerError; - -export type OpResultServerResultCode = OpResultServer["resultCode"]; +import { + type OpResultServer, + type OpResultServerResultCode, + ResultCodes, +} from "@ensnode/ensnode-sdk"; /** * Get HTTP status code corresponding to the given operation result code. diff --git a/apps/ensapi/src/middleware/registrar-actions.middleware.ts b/apps/ensapi/src/middleware/registrar-actions.middleware.ts index e0dc7a059..bfec4f543 100644 --- a/apps/ensapi/src/middleware/registrar-actions.middleware.ts +++ b/apps/ensapi/src/middleware/registrar-actions.middleware.ts @@ -1,13 +1,14 @@ import config from "@/config"; import { - RegistrarActionsResponseCodes, + buildResultInternalServerError, + buildResultServiceUnavailable, registrarActionsPrerequisites, - serializeRegistrarActionsResponse, } from "@ensnode/ensnode-sdk"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; +import { resultIntoHttpResponse } from "@/lib/result/result-into-http-response"; const logger = makeLogger("registrar-actions.middleware"); @@ -31,20 +32,20 @@ export const registrarActionsApiMiddleware = factory.createMiddleware( async function registrarActionsApiMiddleware(c, next) { // context must be set by the required middleware if (c.var.indexingStatus === undefined) { - throw new Error(`Invariant(registrar-actions.middleware): indexingStatusMiddleware required`); + return resultIntoHttpResponse( + c, + buildResultInternalServerError( + `Invariant(registrar-actions.middleware): indexingStatusMiddleware required.`, + ), + ); } if (!registrarActionsPrerequisites.hasEnsIndexerConfigSupport(config.ensIndexerPublicConfig)) { - return c.json( - serializeRegistrarActionsResponse({ - responseCode: RegistrarActionsResponseCodes.Error, - error: { - message: `Registrar Actions API is not available`, - details: `Connected ENSIndexer must have all following plugins active: ${registrarActionsPrerequisites.requiredPlugins.join(", ")}`, - }, - }), - 500, - ); + const result = buildResultServiceUnavailable(`Registrar Actions API is not available`, { + details: `Connected ENSIndexer must have all following plugins active: ${registrarActionsPrerequisites.requiredPlugins.join(", ")}`, + }); + + return resultIntoHttpResponse(c, result); } if (c.var.indexingStatus instanceof Error) { @@ -54,31 +55,24 @@ export const registrarActionsApiMiddleware = factory.createMiddleware( `Registrar Actions API requested but indexing status is not available in context.`, ); - return c.json( - serializeRegistrarActionsResponse({ - responseCode: RegistrarActionsResponseCodes.Error, - error: { - message: `Registrar Actions API is not available`, - details: `Indexing status is currently unavailable to this ENSApi instance.`, - }, - }), - 500, - ); + const result = buildResultServiceUnavailable(`Registrar Actions API is not available`, { + details: `Indexing status is currently unavailable to this ENSApi instance.`, + }); + + return resultIntoHttpResponse(c, result); } const { omnichainSnapshot } = c.var.indexingStatus.snapshot; - if (!registrarActionsPrerequisites.hasIndexingStatusSupport(omnichainSnapshot.omnichainStatus)) - return c.json( - serializeRegistrarActionsResponse({ - responseCode: RegistrarActionsResponseCodes.Error, - error: { - message: `Registrar Actions API is not available`, - details: `The cached omnichain indexing status of the Connected ENSIndexer must be one of the following ${registrarActionsPrerequisites.supportedIndexingStatusIds.map((statusId) => `"${statusId}"`).join(", ")}.`, - }, - }), - 500, - ); + if ( + !registrarActionsPrerequisites.hasIndexingStatusSupport(omnichainSnapshot.omnichainStatus) + ) { + const result = buildResultServiceUnavailable(`Registrar Actions API is not available`, { + details: `The cached omnichain indexing status of the Connected ENSIndexer must be one of the following ${registrarActionsPrerequisites.supportedIndexingStatusIds.map((statusId) => `"${statusId}"`).join(", ")}.`, + }); + + return resultIntoHttpResponse(c, result); + } await next(); }, diff --git a/packages/ensnode-sdk/src/api/registrar-actions/response.ts b/packages/ensnode-sdk/src/api/registrar-actions/response.ts index 3c2eab094..e27d47824 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/response.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/response.ts @@ -1,6 +1,6 @@ import type { InterpretedName } from "../../ens"; import type { RegistrarAction } from "../../registrars"; -import type { UnixTimestamp } from "../../shared"; +import type { OpResultServer, UnixTimestamp } from "../../shared"; import type { IndexingStatusResponseCodes } from "../indexing-status"; import type { ErrorResponse } from "../shared/errors"; import type { ResponsePageContext } from "../shared/pagination"; @@ -81,3 +81,16 @@ export interface RegistrarActionsResponseError { * at runtime. */ export type RegistrarActionsResponse = RegistrarActionsResponseOk | RegistrarActionsResponseError; + +export interface RegistrarActionsResultOkData { + registrarActions: NamedRegistrarAction[]; + pageContext: ResponsePageContext; +} + +/** + * Registrar Actions Result + * + * Use the `resultCode` field to determine the specific type interpretation + * at runtime. + */ +export type RegistrarActionsResult = OpResultServer; diff --git a/packages/ensnode-sdk/src/api/registrar-actions/serialize.ts b/packages/ensnode-sdk/src/api/registrar-actions/serialize.ts index 5810a32e9..bdd11948c 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/serialize.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/serialize.ts @@ -20,6 +20,12 @@ export function serializeNamedRegistrarAction({ }; } +export function serializeNamedRegistrarActions( + actions: NamedRegistrarAction[], +): SerializedNamedRegistrarAction[] { + return actions.map(serializeNamedRegistrarAction); +} + export function serializeRegistrarActionsResponse( response: RegistrarActionsResponse, ): SerializedRegistrarActionsResponse { diff --git a/packages/ensnode-sdk/src/shared/result/result-base.ts b/packages/ensnode-sdk/src/shared/result/result-base.ts index 7f0477ef8..f3b4b725c 100644 --- a/packages/ensnode-sdk/src/shared/result/result-base.ts +++ b/packages/ensnode-sdk/src/shared/result/result-base.ts @@ -1,3 +1,4 @@ +import type { UnixTimestamp } from "../../shared"; import type { ResultCode, ResultCodeClientError, @@ -32,6 +33,26 @@ export interface AbstractResultOk extends AbstractResult extends AbstractResultOk { + /** + * The minimum indexing cursor timestamp that the data is + * guaranteed to be accurate as of. + * + * Guarantees: + * - `data` is guaranteed to be at least up to `minIndexingCursor`, but + * may be indexed with timestamps higher than `minIndexingCursor`. + * - This guarantee may temporarily be violated during a chain reorg. + * ENSNode automatically recovers from chain reorgs, but during one + * the `minIndexingCursor` may theoretically be some seconds ahead of + * the true state of indexed data. + */ + minIndexingCursor: UnixTimestamp; +} + /** * Abstract representation of an error result. */ diff --git a/packages/ensnode-sdk/src/shared/result/result-common.ts b/packages/ensnode-sdk/src/shared/result/result-common.ts index 0b819db71..ff52ccb83 100644 --- a/packages/ensnode-sdk/src/shared/result/result-common.ts +++ b/packages/ensnode-sdk/src/shared/result/result-common.ts @@ -6,43 +6,62 @@ * builders for successful results as well as various error scenarios. */ -import type { AbstractResultError, AbstractResultOk } from "./result-base"; +import type { UnixTimestamp } from "../../shared"; +import type { + AbstractResultError, + AbstractResultOk, + AbstractResultOkTimestamped, +} from "./result-base"; import { type ResultCode, ResultCodes } from "./result-code"; /************************************************************ * Result OK ************************************************************/ -export type ResultOk = AbstractResultOk; - /** * Builds a result object representing a successful operation. */ -export function buildResultOk(data: TData): ResultOk { +export function buildResultOk(data: TData): AbstractResultOk { return { resultCode: ResultCodes.Ok, data, }; } +/** + * Builds a result object representing a successful operation + * with data guaranteed to be at least up to a certain timestamp. + */ +export function buildResultOkTimestamped( + data: TData, + minIndexingCursor: UnixTimestamp, +): AbstractResultOkTimestamped { + return { + ...buildResultOk(data), + minIndexingCursor, + }; +} + /************************************************************ * Service Unavailable ************************************************************/ export interface ResultServiceUnavailable - extends AbstractResultError {} + extends AbstractResultError {} /** * Builds a result object representing a service unavailable error. */ export const buildResultServiceUnavailable = ( errorMessage?: string, + data?: { details?: string }, suggestRetry: boolean = true, ): ResultServiceUnavailable => { return { resultCode: ResultCodes.ServiceUnavailable, errorMessage: errorMessage ?? "The service is currently unavailable.", suggestRetry, + data, }; }; @@ -229,3 +248,22 @@ export type ResultClientError = | ResultConnectionError | ResultRequestTimeout | ResultClientUnrecognizedOperationResult; + +/************************************************************ + * Server Operation Result Types + ************************************************************/ + +/** + * Type representing a successful server operation result. + */ +export type OpResultServerOk = AbstractResultOk | AbstractResultOkTimestamped; + +/** + * Union type representing all possible server operation results. + */ +export type OpResultServer = OpResultServerOk | ResultServerError; + +/** + * Type representing all possible server operation result codes. + */ +export type OpResultServerResultCode = OpResultServer["resultCode"]; From 412e722c1e9754bbb4b4b8384c99d2053f8d8310 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 19 Jan 2026 06:50:38 +0100 Subject: [PATCH 04/23] Apply PR feedback from AI agents --- apps/ensapi/src/handlers/registrar-actions-api.ts | 13 +++++++++++-- apps/ensapi/src/lib/handlers/validate.ts | 11 ++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/ensapi/src/handlers/registrar-actions-api.ts b/apps/ensapi/src/handlers/registrar-actions-api.ts index 3d392770a..53b09aad6 100644 --- a/apps/ensapi/src/handlers/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/registrar-actions-api.ts @@ -3,6 +3,7 @@ import z from "zod/v4"; import { buildPageContext, + buildResultInternalServerError, buildResultOkTimestamped, buildResultServiceUnavailable, type Node, @@ -182,7 +183,11 @@ app.get( // 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 result = buildResultInternalServerError( + "Invariant(registrar-actions-api): indexingStatus must be available in the application context", + ); + + return resultIntoHttpResponse(c, result); } const query = c.req.valid("query"); @@ -276,7 +281,11 @@ app.get( // 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 result = buildResultInternalServerError( + "Invariant(registrar-actions-api): indexingStatus must be available in the application context", + ); + + return resultIntoHttpResponse(c, result); } const { parentNode } = c.req.valid("param"); diff --git a/apps/ensapi/src/lib/handlers/validate.ts b/apps/ensapi/src/lib/handlers/validate.ts index f928ceaee..b0c26f117 100644 --- a/apps/ensapi/src/lib/handlers/validate.ts +++ b/apps/ensapi/src/lib/handlers/validate.ts @@ -8,10 +8,10 @@ import { buildResultInvalidRequest } from "@ensnode/ensnode-sdk"; import { resultIntoHttpResponse } from "@/lib/result/result-into-http-response"; /** - * Creates a Hono validation middleware with custom error formatting. + * Creates a Hono validation middleware. * - * Wraps the Hono validator with custom error handling that uses the - * errorResponse function for consistent error formatting across the API. + * Wraps the Hono validator with custom error handling that uses standardized + * response data model across the API. * * @param target - The validation target (param, query, json, etc.) * @param schema - The Zod schema to validate against @@ -22,12 +22,13 @@ export const validate = validator(target, schema, (result, c) => { - // if validation failed, return our custom-formatted ErrorResponse instead of default + // Respond with the invalid request result if validation failed. if (!result.success) { // Convert Standard Schema issues to ZodError for consistent formatting const schemaError = new SchemaError(result.error); const zodError = new ZodError(schemaError.issues as ZodError["issues"]); + const errorMessage = prettifyError(zodError); - return resultIntoHttpResponse(c, buildResultInvalidRequest(prettifyError(zodError))); + return resultIntoHttpResponse(c, buildResultInvalidRequest(errorMessage)); } }); From 02fbeb33bd650c2ea33bab569aef235b3934993a Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 19 Jan 2026 08:22:18 +0100 Subject: [PATCH 05/23] Add `buildRouteResponsesDescription` function for ensuring comprehensive HTTP endpoint docs --- .../src/handlers/amirealtime-api.test.ts | 4 +- apps/ensapi/src/handlers/amirealtime-api.ts | 56 ++++++++----------- .../src/handlers/registrar-actions-api.ts | 54 ++++++++---------- .../handlers/route-responses-description.ts | 31 ++++++++++ .../lib/result/result-into-http-response.ts | 10 +--- .../registrar-actions.middleware.ts | 19 +++++-- .../ensnode-sdk/src/api/amirealtime/index.ts | 1 + .../ensnode-sdk/src/api/amirealtime/result.ts | 45 +++++++++++++++ packages/ensnode-sdk/src/api/index.ts | 1 + .../src/api/registrar-actions/index.ts | 1 + .../src/api/registrar-actions/response.ts | 15 +---- .../src/api/registrar-actions/result.ts | 30 ++++++++++ .../src/shared/result/result-common.ts | 6 +- 13 files changed, 178 insertions(+), 95 deletions(-) create mode 100644 apps/ensapi/src/lib/handlers/route-responses-description.ts create mode 100644 packages/ensnode-sdk/src/api/amirealtime/index.ts create mode 100644 packages/ensnode-sdk/src/api/amirealtime/result.ts create mode 100644 packages/ensnode-sdk/src/api/registrar-actions/result.ts diff --git a/apps/ensapi/src/handlers/amirealtime-api.test.ts b/apps/ensapi/src/handlers/amirealtime-api.test.ts index 58b17b03a..917d4ef78 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.test.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.test.ts @@ -214,7 +214,7 @@ describe("amirealtime-api", () => { ); }); - it("should return 500 when indexing status has not been resolved", async () => { + it("should return 503 when indexing status has not been resolved", async () => { // Arrange: set `indexingStatus` context var indexingStatusMiddlewareMock.mockImplementation(async (c, next) => { c.set("indexingStatus", new Error("Network error")); @@ -230,7 +230,7 @@ describe("amirealtime-api", () => { expect(response.status).toBe(503); expect(responseJson).toHaveProperty("errorMessage"); expect(responseJson.errorMessage).toMatch( - /Indexing Status has to be resolved successfully before 'maxWorstCaseDistance' can be applied./, + /Indexing Status must be resolved successfully before 'maxWorstCaseDistance' can be applied./, ); }); }); diff --git a/apps/ensapi/src/handlers/amirealtime-api.ts b/apps/ensapi/src/handlers/amirealtime-api.ts index b1d6c6c00..78de2a95f 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.ts @@ -3,6 +3,7 @@ import { describeRoute } from "hono-openapi"; import z from "zod/v4"; import { + type AmIRealtimeResult, buildResultInternalServerError, buildResultOk, buildResultServiceUnavailable, @@ -12,12 +13,10 @@ import { import { makeDurationSchema } from "@ensnode/ensnode-sdk/internal"; import { params } from "@/lib/handlers/params.schema"; +import { buildRouteResponsesDescription } from "@/lib/handlers/route-responses-description"; import { validate } from "@/lib/handlers/validate"; import { factory } from "@/lib/hono-factory"; -import { - resultCodeToHttpStatusCode, - resultIntoHttpResponse, -} from "@/lib/result/result-into-http-response"; +import { resultIntoHttpResponse } from "@/lib/result/result-into-http-response"; const app = factory.createApp(); @@ -33,19 +32,19 @@ app.get( summary: "Check indexing progress", description: "Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime", - responses: { - [resultCodeToHttpStatusCode(ResultCodes.Ok)]: { + responses: buildRouteResponsesDescription({ + [ResultCodes.Ok]: { description: "Indexing progress is guaranteed to be within the requested distance of realtime", }, - [resultCodeToHttpStatusCode(ResultCodes.InternalServerError)]: { + [ResultCodes.InternalServerError]: { description: "Indexing progress cannot be determined due to an internal server error", }, - [resultCodeToHttpStatusCode(ResultCodes.ServiceUnavailable)]: { + [ResultCodes.ServiceUnavailable]: { description: "Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable", }, - }, + }), }), validate( "query", @@ -58,45 +57,38 @@ app.get( }), ), async (c) => { - // context must be set by the required middleware + // Invariant: Indexing Status must be available in application context if (c.var.indexingStatus === undefined) { - return resultIntoHttpResponse( - c, - buildResultInternalServerError( - `Invariant(amirealtime-api): indexingStatusMiddleware required.`, - ), + const result = buildResultInternalServerError( + `Invariant(amirealtime-api): Indexing Status must be available in application context.`, ); + + return resultIntoHttpResponse(c, result); } - // return 503 response error with details on prerequisite being unavailable + // Invariant: Indexing Status must be resolved successfully before 'maxWorstCaseDistance' can be applied if (c.var.indexingStatus instanceof Error) { - return resultIntoHttpResponse( - c, - // todo: differentiate between 500 vs. 503 based on error type - buildResultServiceUnavailable( - `Invariant(amirealtime-api): Indexing Status has to be resolved successfully before 'maxWorstCaseDistance' can be applied.`, - ), + const result = buildResultServiceUnavailable( + `Invariant(amirealtime-api): Indexing Status must be resolved successfully before 'maxWorstCaseDistance' can be applied.`, ); + + return resultIntoHttpResponse(c, result); } 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` + // Case: worst-case distance exceeds requested maximum if (worstCaseDistance > maxWorstCaseDistance) { - // todo: differentiate between 500 vs. 503 based on error type - return resultIntoHttpResponse( - c, - buildResultServiceUnavailable( - `Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = ${worstCaseDistance}; maxWorstCaseDistance = ${maxWorstCaseDistance}`, - ), + const result = buildResultServiceUnavailable( + `Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = ${worstCaseDistance}; maxWorstCaseDistance = ${maxWorstCaseDistance}`, ); + + return resultIntoHttpResponse(c, result); } - // return 200 response OK with current details on `maxWorstCaseDistance`, - // `slowestChainIndexingCursor`, and `worstCaseDistance` + // Case: worst-case distance is within requested maximum return resultIntoHttpResponse( c, buildResultOk({ diff --git a/apps/ensapi/src/handlers/registrar-actions-api.ts b/apps/ensapi/src/handlers/registrar-actions-api.ts index 53b09aad6..c36054e40 100644 --- a/apps/ensapi/src/handlers/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/registrar-actions-api.ts @@ -3,7 +3,6 @@ import z from "zod/v4"; import { buildPageContext, - buildResultInternalServerError, buildResultOkTimestamped, buildResultServiceUnavailable, type Node, @@ -11,6 +10,7 @@ import { RECORDS_PER_PAGE_MAX, type RegistrarActionsFilter, RegistrarActionsOrders, + type RegistrarActionsResult, ResultCodes, registrarActionsFilter, serializeNamedRegistrarActions, @@ -23,14 +23,12 @@ import { } from "@ensnode/ensnode-sdk/internal"; import { params } from "@/lib/handlers/params.schema"; +import { buildRouteResponsesDescription } from "@/lib/handlers/route-responses-description"; import { validate } from "@/lib/handlers/validate"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; import { findRegistrarActions } from "@/lib/registrar-actions/find-registrar-actions"; -import { - resultCodeToHttpStatusCode, - resultIntoHttpResponse, -} from "@/lib/result/result-into-http-response"; +import { resultIntoHttpResponse } from "@/lib/result/result-into-http-response"; import { registrarActionsApiMiddleware } from "@/middleware/registrar-actions.middleware"; const app = factory.createApp(); @@ -152,6 +150,18 @@ async function fetchRegistrarActions( return { registrarActions, pageContext }; } +const routeResponsesDescription = buildRouteResponsesDescription({ + [ResultCodes.Ok]: { + description: "Successfully retrieved registrar actions", + }, + [ResultCodes.ServiceUnavailable]: { + description: "Registrar Actions API is unavailable at the moment", + }, + [ResultCodes.InternalServerError]: { + description: "An internal server error occurred", + }, +}); + /** * Get Registrar Actions (all records) * @@ -165,25 +175,16 @@ app.get( tags: ["Explore"], summary: "Get Registrar Actions", description: "Returns all registrar actions with optional filtering and pagination", - responses: { - [resultCodeToHttpStatusCode(ResultCodes.Ok)]: { - description: "Successfully retrieved registrar actions", - }, - [resultCodeToHttpStatusCode(ResultCodes.InvalidRequest)]: { - description: "Invalid query", - }, - [resultCodeToHttpStatusCode(ResultCodes.ServiceUnavailable)]: { - description: "Registrar Actions API is unavailable at the moment", - }, - }, + responses: routeResponsesDescription, }), validate("query", registrarActionsQuerySchema), async (c) => { try { // Middleware ensures indexingStatus is available and not an Error - // This check is for TypeScript type safety + // This check is for TypeScript type safety, should never occur in + // practice. if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) { - const result = buildResultInternalServerError( + const result = buildResultServiceUnavailable( "Invariant(registrar-actions-api): indexingStatus must be available in the application context", ); @@ -255,17 +256,7 @@ app.get( summary: "Get Registrar Actions by Parent Node", description: "Returns registrar actions filtered by parent node hash with optional additional filtering and pagination", - responses: { - [resultCodeToHttpStatusCode(ResultCodes.Ok)]: { - description: "Successfully retrieved registrar actions", - }, - [resultCodeToHttpStatusCode(ResultCodes.InvalidRequest)]: { - description: "Invalid query", - }, - [resultCodeToHttpStatusCode(ResultCodes.ServiceUnavailable)]: { - description: "Registrar Actions API is unavailable at the moment", - }, - }, + responses: routeResponsesDescription, }), validate( "param", @@ -279,9 +270,10 @@ app.get( async (c) => { try { // Middleware ensures indexingStatus is available and not an Error - // This check is for TypeScript type safety + // This check is for TypeScript type safety, should never occur in + // practice. if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) { - const result = buildResultInternalServerError( + const result = buildResultServiceUnavailable( "Invariant(registrar-actions-api): indexingStatus must be available in the application context", ); diff --git a/apps/ensapi/src/lib/handlers/route-responses-description.ts b/apps/ensapi/src/lib/handlers/route-responses-description.ts new file mode 100644 index 000000000..bb147c360 --- /dev/null +++ b/apps/ensapi/src/lib/handlers/route-responses-description.ts @@ -0,0 +1,31 @@ +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +import type { ResultServer } from "@ensnode/ensnode-sdk"; + +import { resultCodeToHttpStatusCode } from "@/lib/result/result-into-http-response"; + +interface RouteDescription { + description: string; +} + +/** + * Builds a mapping of HTTP status codes to route descriptions + * from a mapping of operation result codes to route descriptions. + * + * @param routes - A record mapping operation result codes to route descriptions + * @returns A record mapping HTTP status codes to route descriptions + */ +export function buildRouteResponsesDescription( + routes: Record, +): Record { + return Object.entries(routes).reduce( + (acc, entry) => { + const [resultCode, desc] = entry as [TResult["resultCode"], RouteDescription]; + + acc[resultCodeToHttpStatusCode(resultCode as TResult["resultCode"])] = desc; + + return acc; + }, + {} as Record, + ); +} diff --git a/apps/ensapi/src/lib/result/result-into-http-response.ts b/apps/ensapi/src/lib/result/result-into-http-response.ts index 38d23c65b..161a052e2 100644 --- a/apps/ensapi/src/lib/result/result-into-http-response.ts +++ b/apps/ensapi/src/lib/result/result-into-http-response.ts @@ -1,11 +1,7 @@ import type { Context } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; -import { - type OpResultServer, - type OpResultServerResultCode, - ResultCodes, -} from "@ensnode/ensnode-sdk"; +import { ResultCodes, type ResultServer, type ResultServerResultCode } from "@ensnode/ensnode-sdk"; /** * Get HTTP status code corresponding to the given operation result code. @@ -14,7 +10,7 @@ import { * @returns Corresponding HTTP status code */ export function resultCodeToHttpStatusCode( - resultCode: OpResultServerResultCode, + resultCode: ResultServerResultCode, ): ContentfulStatusCode { switch (resultCode) { case ResultCodes.Ok: @@ -37,7 +33,7 @@ export function resultCodeToHttpStatusCode( * @param result - The operation result * @returns HTTP response with appropriate status code and JSON body */ -export function resultIntoHttpResponse( +export function resultIntoHttpResponse( c: Context, result: TResult, ): Response { diff --git a/apps/ensapi/src/middleware/registrar-actions.middleware.ts b/apps/ensapi/src/middleware/registrar-actions.middleware.ts index bfec4f543..a4b6a037b 100644 --- a/apps/ensapi/src/middleware/registrar-actions.middleware.ts +++ b/apps/ensapi/src/middleware/registrar-actions.middleware.ts @@ -3,6 +3,8 @@ import config from "@/config"; import { buildResultInternalServerError, buildResultServiceUnavailable, + ResultInternalServerError, + ResultServiceUnavailable, registrarActionsPrerequisites, } from "@ensnode/ensnode-sdk"; @@ -18,7 +20,13 @@ const logger = makeLogger("registrar-actions.middleware"); * This middleware that ensures that all prerequisites of * the Registrar Actions API were met and HTTP requests can be served. * - * Returns a 500 response for any of the following cases: + * Returns a response from {@link ResultInternalServerError} for any of + * the following cases: + * 1) The application context does not have the indexing status set by + * `indexingStatusMiddleware`. + * + * Returns a response from {@link ResultServiceUnavailable} for any of + * the following cases: * 1) Not all required plugins are active in the connected ENSIndexer * configuration. * 2) ENSApi has not yet successfully cached the Indexing Status in memory from @@ -32,12 +40,11 @@ export const registrarActionsApiMiddleware = factory.createMiddleware( async function registrarActionsApiMiddleware(c, next) { // context must be set by the required middleware if (c.var.indexingStatus === undefined) { - return resultIntoHttpResponse( - c, - buildResultInternalServerError( - `Invariant(registrar-actions.middleware): indexingStatusMiddleware required.`, - ), + const result = buildResultInternalServerError( + `Invariant(registrar-actions.middleware): indexingStatusMiddleware required.`, ); + + return resultIntoHttpResponse(c, result); } if (!registrarActionsPrerequisites.hasEnsIndexerConfigSupport(config.ensIndexerPublicConfig)) { diff --git a/packages/ensnode-sdk/src/api/amirealtime/index.ts b/packages/ensnode-sdk/src/api/amirealtime/index.ts new file mode 100644 index 000000000..bb162e4c8 --- /dev/null +++ b/packages/ensnode-sdk/src/api/amirealtime/index.ts @@ -0,0 +1 @@ +export * from "./result"; diff --git a/packages/ensnode-sdk/src/api/amirealtime/result.ts b/packages/ensnode-sdk/src/api/amirealtime/result.ts new file mode 100644 index 000000000..8e78dccec --- /dev/null +++ b/packages/ensnode-sdk/src/api/amirealtime/result.ts @@ -0,0 +1,45 @@ +import type { RealtimeIndexingStatusProjection } from "../../ensindexer"; +import type { + Duration, + ResultInternalServerError, + ResultServerOk, + ResultServiceUnavailable, + UnixTimestamp, +} from "../../shared"; + +export interface AmIRealtimeResultOkData { + /** + * Represents the maximum worst-case distance from the current "tip" of + * all indexed chains. `maxWorstCaseDistance` is defined by the client + * making the request. + */ + maxWorstCaseDistance: Duration; + + /** + * Worst-case distance in seconds. + * + * See {@link RealtimeIndexingStatusProjection.worstCaseDistance} for details. + * + * Guarantees: + * - `worstCaseDistance` is always less than or equal to `maxWorstCaseDistance`. + */ + worstCaseDistance: Duration; + + /** + * The timestamp of the "slowest" latest indexed block timestamp across all indexed chains. + * + * See {@link RealtimeIndexingStatusProjection.slowestChainIndexingCursor} for details. + */ + slowestChainIndexingCursor: UnixTimestamp; +} + +/** + * The operation result for "Am I Realtime?" API requests. + * + * Use the `resultCode` field to determine the specific type interpretation + * at runtime. + */ +export type AmIRealtimeResult = + | ResultServerOk + | ResultInternalServerError + | ResultServiceUnavailable; diff --git a/packages/ensnode-sdk/src/api/index.ts b/packages/ensnode-sdk/src/api/index.ts index 661de1ea7..e2b3c40d4 100644 --- a/packages/ensnode-sdk/src/api/index.ts +++ b/packages/ensnode-sdk/src/api/index.ts @@ -1,3 +1,4 @@ +export * from "./amirealtime"; export * from "./config"; export * from "./indexing-status"; export * from "./name-tokens"; diff --git a/packages/ensnode-sdk/src/api/registrar-actions/index.ts b/packages/ensnode-sdk/src/api/registrar-actions/index.ts index 8f0b7c3f5..27366182b 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/index.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/index.ts @@ -3,5 +3,6 @@ export * from "./filters"; export * from "./prerequisites"; export * from "./request"; export * from "./response"; +export * from "./result"; export * from "./serialize"; export * from "./serialized-response"; diff --git a/packages/ensnode-sdk/src/api/registrar-actions/response.ts b/packages/ensnode-sdk/src/api/registrar-actions/response.ts index e27d47824..3c2eab094 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/response.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/response.ts @@ -1,6 +1,6 @@ import type { InterpretedName } from "../../ens"; import type { RegistrarAction } from "../../registrars"; -import type { OpResultServer, UnixTimestamp } from "../../shared"; +import type { UnixTimestamp } from "../../shared"; import type { IndexingStatusResponseCodes } from "../indexing-status"; import type { ErrorResponse } from "../shared/errors"; import type { ResponsePageContext } from "../shared/pagination"; @@ -81,16 +81,3 @@ export interface RegistrarActionsResponseError { * at runtime. */ export type RegistrarActionsResponse = RegistrarActionsResponseOk | RegistrarActionsResponseError; - -export interface RegistrarActionsResultOkData { - registrarActions: NamedRegistrarAction[]; - pageContext: ResponsePageContext; -} - -/** - * Registrar Actions Result - * - * Use the `resultCode` field to determine the specific type interpretation - * at runtime. - */ -export type RegistrarActionsResult = OpResultServer; diff --git a/packages/ensnode-sdk/src/api/registrar-actions/result.ts b/packages/ensnode-sdk/src/api/registrar-actions/result.ts new file mode 100644 index 000000000..17b465d95 --- /dev/null +++ b/packages/ensnode-sdk/src/api/registrar-actions/result.ts @@ -0,0 +1,30 @@ +import type { + ResultInternalServerError, + ResultServerOk, + ResultServiceUnavailable, +} from "../../shared"; +import type { ResponsePageContext } from "../shared"; +import type { NamedRegistrarAction } from "../types"; + +export interface RegistrarActionsResultOkData { + /** + * The list of "logical registrar actions" with their associated names. + */ + registrarActions: NamedRegistrarAction[]; + + /** + * The pagination context for the current page of results. + */ + pageContext: ResponsePageContext; +} + +/** + * The operation result for Registrar Actions API requests. + * + * Use the `resultCode` field to determine the specific type interpretation + * at runtime. + */ +export type RegistrarActionsResult = + | ResultServerOk + | ResultInternalServerError + | ResultServiceUnavailable; diff --git a/packages/ensnode-sdk/src/shared/result/result-common.ts b/packages/ensnode-sdk/src/shared/result/result-common.ts index ff52ccb83..6a34b61d1 100644 --- a/packages/ensnode-sdk/src/shared/result/result-common.ts +++ b/packages/ensnode-sdk/src/shared/result/result-common.ts @@ -256,14 +256,14 @@ export type ResultClientError = /** * Type representing a successful server operation result. */ -export type OpResultServerOk = AbstractResultOk | AbstractResultOkTimestamped; +export type ResultServerOk = AbstractResultOk | AbstractResultOkTimestamped; /** * Union type representing all possible server operation results. */ -export type OpResultServer = OpResultServerOk | ResultServerError; +export type ResultServer = ResultServerOk | ResultServerError; /** * Type representing all possible server operation result codes. */ -export type OpResultServerResultCode = OpResultServer["resultCode"]; +export type ResultServerResultCode = ResultServer["resultCode"]; From ac5bb118e8397302a72124e729bc1bc36435d488 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 19 Jan 2026 09:03:31 +0100 Subject: [PATCH 06/23] Handle "unhandled result code" while generating HTTP response from a Result object --- .../result/result-into-http-response.test.ts | 102 +++++++++--------- .../lib/result/result-into-http-response.ts | 31 +++++- .../src/shared/result/result-common.ts | 2 +- 3 files changed, 83 insertions(+), 52 deletions(-) diff --git a/apps/ensapi/src/lib/result/result-into-http-response.test.ts b/apps/ensapi/src/lib/result/result-into-http-response.test.ts index de3b261c6..fdf6cedfd 100644 --- a/apps/ensapi/src/lib/result/result-into-http-response.test.ts +++ b/apps/ensapi/src/lib/result/result-into-http-response.test.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; -import { describe, expect, it, vi } from "vitest"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { buildResultInternalServerError, @@ -8,6 +9,8 @@ import { buildResultOk, buildResultServiceUnavailable, ResultCodes, + type ResultServer, + type ResultServerResultCode, } from "@ensnode/ensnode-sdk"; import { resultCodeToHttpStatusCode, resultIntoHttpResponse } from "./result-into-http-response"; @@ -42,90 +45,93 @@ describe("resultCodeToHttpStatusCode", () => { expect(statusCode).toBe(503); }); + + it("should throw an error for unhandled result code", () => { + const unhandledResultCode = "test" as ResultServerResultCode; + expect(() => resultCodeToHttpStatusCode(unhandledResultCode)).toThrowError( + `Unhandled result code: ${unhandledResultCode}`, + ); + }); }); describe("resultIntoHttpResponse", () => { - it("should return HTTP response with status 200 for Ok result", () => { - const mockResponse = { status: 200, body: "test" }; - const mockContext = { - json: vi.fn().mockReturnValue(mockResponse), + let mockContext: Context; + + beforeEach(() => { + mockContext = { + json: vi.fn((object: Response, status: ContentfulStatusCode) => { + return Response.json(object, { status }); + }), } as unknown as Context; + }); + it("should return HTTP response with status 200 for Ok result", async () => { const result = buildResultOk("test data"); const response = resultIntoHttpResponse(mockContext, result); - expect(mockContext.json).toHaveBeenCalledWith(result, 200); - expect(response).toBe(mockResponse); + expect(response.status).toBe(200); + expect(await response.json()).toStrictEqual(result); }); - it("should return HTTP response with status 400 for InvalidRequest result", () => { - const mockResponse = { status: 400, body: "error" }; - const mockContext = { - json: vi.fn().mockReturnValue(mockResponse), - } as unknown as Context; - + it("should return HTTP response with status 400 for InvalidRequest result", async () => { const result = buildResultInvalidRequest("Invalid request"); const response = resultIntoHttpResponse(mockContext, result); - expect(mockContext.json).toHaveBeenCalledWith(result, 400); - expect(response).toBe(mockResponse); + expect(response.status).toBe(400); + expect(await response.json()).toStrictEqual(result); }); - it("should return HTTP response with status 404 for NotFound result", () => { - const mockResponse = { status: 404, body: "not found" }; - const mockContext = { - json: vi.fn().mockReturnValue(mockResponse), - } as unknown as Context; - + it("should return HTTP response with status 404 for NotFound result", async () => { const result = buildResultNotFound("Resource not found"); const response = resultIntoHttpResponse(mockContext, result); - expect(mockContext.json).toHaveBeenCalledWith(result, 404); - expect(response).toBe(mockResponse); + expect(response.status).toBe(404); + expect(await response.json()).toStrictEqual(result); }); - it("should return HTTP response with status 500 for InternalServerError result", () => { - const mockResponse = { status: 500, body: "Internal server error" }; - const mockContext = { - json: vi.fn().mockReturnValue(mockResponse), - } as unknown as Context; - + it("should return HTTP response with status 500 for InternalServerError result", async () => { const result = buildResultInternalServerError("Internal server error"); const response = resultIntoHttpResponse(mockContext, result); - expect(mockContext.json).toHaveBeenCalledWith(result, 500); - expect(response).toBe(mockResponse); + expect(response.status).toBe(500); + expect(await response.json()).toStrictEqual(result); }); - it("should return HTTP response with status 503 for ServiceUnavailable result", () => { - const mockResponse = { status: 503, body: "unavailable" }; - const mockContext = { - json: vi.fn().mockReturnValue(mockResponse), - } as unknown as Context; - + it("should return HTTP response with status 503 for ServiceUnavailable result", async () => { const result = buildResultServiceUnavailable("Service unavailable"); const response = resultIntoHttpResponse(mockContext, result); - expect(mockContext.json).toHaveBeenCalledWith(result, 503); - expect(response).toBe(mockResponse); + expect(response.status).toBe(503); + const responseJson = await response.json(); + expect(responseJson).toStrictEqual(result); }); - it("should handle result with complex data object", () => { - const mockResponse = { status: 200, body: "complex" }; - const mockContext = { - json: vi.fn().mockReturnValue(mockResponse), - } as unknown as Context; - - const result = buildResultOk({ id: 1, name: "Test" }); + it("should handle result with complex data object", async () => { + const complexData = { id: 1, name: "Test", attributes: { key: "value" } }; + const result = buildResultOk(complexData); const response = resultIntoHttpResponse(mockContext, result); + expect(response.status).toBe(200); + expect(await response.json()).toStrictEqual(result); + }); + + it("should handle result with with unhandled result code", async () => { + const unhandledResultCode = "test" as ResultServerResultCode; + const result = { + resultCode: unhandledResultCode, + errorMessage: "Unhandled result code", + }; + + const response = resultIntoHttpResponse(mockContext, result as ResultServer); - expect(mockContext.json).toHaveBeenCalledWith(result, 200); - expect(response).toBe(mockResponse); + expect(response.status).toBe(500); + expect(await response.json()).toStrictEqual( + buildResultInternalServerError("An internal server error occurred."), + ); }); }); diff --git a/apps/ensapi/src/lib/result/result-into-http-response.ts b/apps/ensapi/src/lib/result/result-into-http-response.ts index 161a052e2..fd3b336a6 100644 --- a/apps/ensapi/src/lib/result/result-into-http-response.ts +++ b/apps/ensapi/src/lib/result/result-into-http-response.ts @@ -1,13 +1,23 @@ import type { Context } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; -import { ResultCodes, type ResultServer, type ResultServerResultCode } from "@ensnode/ensnode-sdk"; +import { + buildResultInternalServerError, + ResultCodes, + type ResultServer, + type ResultServerResultCode, +} from "@ensnode/ensnode-sdk"; + +import { makeLogger } from "@/lib/logger"; + +const logger = makeLogger("result-into-http-response"); /** * Get HTTP status code corresponding to the given operation result code. * * @param resultCode - The operation result code * @returns Corresponding HTTP status code + * @throws Error if the result code is unhandled */ export function resultCodeToHttpStatusCode( resultCode: ResultServerResultCode, @@ -23,6 +33,8 @@ export function resultCodeToHttpStatusCode( return 500; case ResultCodes.ServiceUnavailable: return 503; + default: + throw new Error(`Unhandled result code: ${resultCode}`); } } @@ -37,7 +49,20 @@ export function resultIntoHttpResponse( c: Context, result: TResult, ): Response { - const statusCode = resultCodeToHttpStatusCode(result.resultCode); + try { + // Determine HTTP status code from result code + const statusCode = resultCodeToHttpStatusCode(result.resultCode); - return c.json(result, statusCode); + // Return JSON response with appropriate status code + return c.json(result, statusCode); + } catch { + // In case of unhandled result code, log error and + // return response from internal server error result + logger.error(`Unhandled result code encountered: ${result.resultCode}`); + + return resultIntoHttpResponse( + c, + buildResultInternalServerError("An internal server error occurred."), + ); + } } diff --git a/packages/ensnode-sdk/src/shared/result/result-common.ts b/packages/ensnode-sdk/src/shared/result/result-common.ts index 6a34b61d1..cdf0c1876 100644 --- a/packages/ensnode-sdk/src/shared/result/result-common.ts +++ b/packages/ensnode-sdk/src/shared/result/result-common.ts @@ -61,7 +61,7 @@ export const buildResultServiceUnavailable = ( resultCode: ResultCodes.ServiceUnavailable, errorMessage: errorMessage ?? "The service is currently unavailable.", suggestRetry, - data, + ...(data ? { data } : {}), }; }; From 1cd4b10f618b977414e8f58abe69a99369fed5bb Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 19 Jan 2026 09:06:34 +0100 Subject: [PATCH 07/23] Improve code style in Registrar Actions API --- .../src/handlers/registrar-actions-api.ts | 52 ++++++++++--------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/apps/ensapi/src/handlers/registrar-actions-api.ts b/apps/ensapi/src/handlers/registrar-actions-api.ts index c36054e40..0311a5227 100644 --- a/apps/ensapi/src/handlers/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/registrar-actions-api.ts @@ -179,19 +179,20 @@ app.get( }), validate("query", registrarActionsQuerySchema), async (c) => { - try { - // Middleware ensures indexingStatus is available and not an Error - // This check is for TypeScript type safety, should never occur in - // practice. - if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) { - const result = buildResultServiceUnavailable( - "Invariant(registrar-actions-api): indexingStatus must be available in the application context", - ); - - return resultIntoHttpResponse(c, result); - } + // Middleware ensures indexingStatus is available and not an Error + // This check is for TypeScript type safety, should never occur in + // practice. + if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) { + const result = buildResultServiceUnavailable( + "Invariant(registrar-actions-api): indexingStatus must be available in the application context", + ); + + return resultIntoHttpResponse(c, result); + } - const query = c.req.valid("query"); + const query = c.req.valid("query"); + + try { const { registrarActions, pageContext } = await fetchRegistrarActions(undefined, query); // Get the accurateAsOf timestamp from the slowest chain indexing cursor @@ -268,20 +269,21 @@ app.get( ), validate("query", registrarActionsQuerySchema), async (c) => { - try { - // Middleware ensures indexingStatus is available and not an Error - // This check is for TypeScript type safety, should never occur in - // practice. - if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) { - const result = buildResultServiceUnavailable( - "Invariant(registrar-actions-api): indexingStatus must be available in the application context", - ); - - return resultIntoHttpResponse(c, result); - } + // Middleware ensures indexingStatus is available and not an Error + // This check is for TypeScript type safety, should never occur in + // practice. + if (!c.var.indexingStatus || c.var.indexingStatus instanceof Error) { + const result = buildResultServiceUnavailable( + "Invariant(registrar-actions-api): indexingStatus must be available in the application context", + ); + + return resultIntoHttpResponse(c, result); + } - const { parentNode } = c.req.valid("param"); - const query = c.req.valid("query"); + const { parentNode } = c.req.valid("param"); + const query = c.req.valid("query"); + + try { const { registrarActions, pageContext } = await fetchRegistrarActions(parentNode, query); // Get the accurateAsOf timestamp from the slowest chain indexing cursor From a649eddea7e7d28e201aba059418f6ac5d1a2e3b Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 19 Jan 2026 09:11:39 +0100 Subject: [PATCH 08/23] Fix typos --- apps/ensapi/src/lib/result/result-into-http-response.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensapi/src/lib/result/result-into-http-response.test.ts b/apps/ensapi/src/lib/result/result-into-http-response.test.ts index fdf6cedfd..3786b5360 100644 --- a/apps/ensapi/src/lib/result/result-into-http-response.test.ts +++ b/apps/ensapi/src/lib/result/result-into-http-response.test.ts @@ -120,7 +120,7 @@ describe("resultIntoHttpResponse", () => { expect(await response.json()).toStrictEqual(result); }); - it("should handle result with with unhandled result code", async () => { + it("should handle result with unhandled result code", async () => { const unhandledResultCode = "test" as ResultServerResultCode; const result = { resultCode: unhandledResultCode, From d6575b5a97116aa7e00f2de6a30db90ba33f46a8 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 19 Jan 2026 10:52:50 +0100 Subject: [PATCH 09/23] Fix type casting --- apps/ensapi/src/lib/handlers/route-responses-description.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ensapi/src/lib/handlers/route-responses-description.ts b/apps/ensapi/src/lib/handlers/route-responses-description.ts index bb147c360..806446ab1 100644 --- a/apps/ensapi/src/lib/handlers/route-responses-description.ts +++ b/apps/ensapi/src/lib/handlers/route-responses-description.ts @@ -22,7 +22,7 @@ export function buildRouteResponsesDescription( (acc, entry) => { const [resultCode, desc] = entry as [TResult["resultCode"], RouteDescription]; - acc[resultCodeToHttpStatusCode(resultCode as TResult["resultCode"])] = desc; + acc[resultCodeToHttpStatusCode(resultCode)] = desc; return acc; }, From 1246ce707c38d308a0f3c5c3fd8ef9394d6d8c20 Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Mon, 19 Jan 2026 11:02:58 +0100 Subject: [PATCH 10/23] Include InvalidRequest result code in docs --- apps/ensapi/src/handlers/amirealtime-api.ts | 3 +++ apps/ensapi/src/handlers/registrar-actions-api.ts | 3 +++ packages/ensnode-sdk/src/api/amirealtime/result.ts | 2 ++ packages/ensnode-sdk/src/api/registrar-actions/result.ts | 2 ++ 4 files changed, 10 insertions(+) diff --git a/apps/ensapi/src/handlers/amirealtime-api.ts b/apps/ensapi/src/handlers/amirealtime-api.ts index 78de2a95f..29d518d5f 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.ts @@ -37,6 +37,9 @@ app.get( description: "Indexing progress is guaranteed to be within the requested distance of realtime", }, + [ResultCodes.InvalidRequest]: { + description: "Invalid request parameters", + }, [ResultCodes.InternalServerError]: { description: "Indexing progress cannot be determined due to an internal server error", }, diff --git a/apps/ensapi/src/handlers/registrar-actions-api.ts b/apps/ensapi/src/handlers/registrar-actions-api.ts index 0311a5227..40d3260e0 100644 --- a/apps/ensapi/src/handlers/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/registrar-actions-api.ts @@ -154,6 +154,9 @@ const routeResponsesDescription = buildRouteResponsesDescription + | ResultInvalidRequest | ResultInternalServerError | ResultServiceUnavailable; diff --git a/packages/ensnode-sdk/src/api/registrar-actions/result.ts b/packages/ensnode-sdk/src/api/registrar-actions/result.ts index 17b465d95..7993233f4 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/result.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/result.ts @@ -1,5 +1,6 @@ import type { ResultInternalServerError, + ResultInvalidRequest, ResultServerOk, ResultServiceUnavailable, } from "../../shared"; @@ -26,5 +27,6 @@ export interface RegistrarActionsResultOkData { */ export type RegistrarActionsResult = | ResultServerOk + | ResultInvalidRequest | ResultInternalServerError | ResultServiceUnavailable; From 2d86cc20972aea05f336650d9b6d7f69dd9c98cd Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Tue, 20 Jan 2026 16:37:41 +0100 Subject: [PATCH 11/23] Apply PR feedback: use concrete Result OK types --- .../src/handlers/amirealtime-api.test.ts | 32 +++++---- apps/ensapi/src/handlers/amirealtime-api.ts | 24 +++---- .../src/handlers/registrar-actions-api.ts | 20 +++--- apps/ensapi/src/index.ts | 6 +- .../handlers/route-responses-description.ts | 9 ++- .../result/result-into-http-response.test.ts | 29 +------- .../lib/result/result-into-http-response.ts | 39 +++------- .../registrar-actions.middleware.ts | 31 +++++--- .../ensnode-sdk/src/api/amirealtime/result.ts | 34 ++++++--- packages/ensnode-sdk/src/api/health/index.ts | 1 + packages/ensnode-sdk/src/api/health/result.ts | 23 ++++++ packages/ensnode-sdk/src/api/index.ts | 1 + .../src/api/registrar-actions/result.ts | 72 ++++++++++++++++--- .../src/shared/result/result-code.ts | 12 ++-- .../src/shared/result/result-common.ts | 72 +------------------ 15 files changed, 208 insertions(+), 197 deletions(-) create mode 100644 packages/ensnode-sdk/src/api/health/index.ts create mode 100644 packages/ensnode-sdk/src/api/health/result.ts diff --git a/apps/ensapi/src/handlers/amirealtime-api.test.ts b/apps/ensapi/src/handlers/amirealtime-api.test.ts index 917d4ef78..4c76f25be 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.test.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - buildResultOk, + buildAmIRealtimeResultOk, type CrossChainIndexingStatusSnapshot, createRealtimeIndexingStatusProjection, type UnixTimestamp, @@ -70,9 +70,11 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); - expect(responseJson).toMatchObject( - buildResultOk({ + expect(responseJson).toStrictEqual( + buildAmIRealtimeResultOk({ maxWorstCaseDistance: 300, + worstCaseDistance: 10, + slowestChainIndexingCursor: now - 10, }), ); }); @@ -87,9 +89,11 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); - expect(responseJson).toMatchObject( - buildResultOk({ + expect(responseJson).toStrictEqual( + buildAmIRealtimeResultOk({ maxWorstCaseDistance: 0, + worstCaseDistance: 0, + slowestChainIndexingCursor: now, }), ); }); @@ -104,9 +108,11 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); - expect(responseJson).toMatchObject( - buildResultOk({ + expect(responseJson).toStrictEqual( + buildAmIRealtimeResultOk({ maxWorstCaseDistance: AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE, + worstCaseDistance: 10, + slowestChainIndexingCursor: now - 10, }), ); }); @@ -121,9 +127,11 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); - expect(responseJson).toMatchObject( - buildResultOk({ + expect(responseJson).toStrictEqual( + buildAmIRealtimeResultOk({ maxWorstCaseDistance: AMIREALTIME_DEFAULT_MAX_WORST_CASE_DISTANCE, + worstCaseDistance: 10, + slowestChainIndexingCursor: now - 10, }), ); }); @@ -171,9 +179,9 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); expect(responseJson).toStrictEqual( - buildResultOk({ + buildAmIRealtimeResultOk({ maxWorstCaseDistance: 10, - slowestChainIndexingCursor: 1766123720, + slowestChainIndexingCursor: now - 9, worstCaseDistance: 9, }), ); @@ -190,7 +198,7 @@ describe("amirealtime-api", () => { // Assert expect(response.status).toBe(200); expect(responseJson).toStrictEqual( - buildResultOk({ + buildAmIRealtimeResultOk({ maxWorstCaseDistance: 10, slowestChainIndexingCursor: 1766123719, worstCaseDistance: 10, diff --git a/apps/ensapi/src/handlers/amirealtime-api.ts b/apps/ensapi/src/handlers/amirealtime-api.ts index 29d518d5f..fedd415a4 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.ts @@ -3,9 +3,9 @@ import { describeRoute } from "hono-openapi"; import z from "zod/v4"; import { - type AmIRealtimeResult, + type AmIRealtimeServerResult, + buildAmIRealtimeResultOk, buildResultInternalServerError, - buildResultOk, buildResultServiceUnavailable, type Duration, ResultCodes, @@ -32,7 +32,7 @@ app.get( summary: "Check indexing progress", description: "Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime", - responses: buildRouteResponsesDescription({ + responses: buildRouteResponsesDescription({ [ResultCodes.Ok]: { description: "Indexing progress is guaranteed to be within the requested distance of realtime", @@ -60,7 +60,6 @@ app.get( }), ), async (c) => { - // Invariant: Indexing Status must be available in application context if (c.var.indexingStatus === undefined) { const result = buildResultInternalServerError( `Invariant(amirealtime-api): Indexing Status must be available in application context.`, @@ -69,7 +68,6 @@ app.get( return resultIntoHttpResponse(c, result); } - // Invariant: Indexing Status must be resolved successfully before 'maxWorstCaseDistance' can be applied if (c.var.indexingStatus instanceof Error) { const result = buildResultServiceUnavailable( `Invariant(amirealtime-api): Indexing Status must be resolved successfully before 'maxWorstCaseDistance' can be applied.`, @@ -92,14 +90,14 @@ app.get( } // Case: worst-case distance is within requested maximum - return resultIntoHttpResponse( - c, - buildResultOk({ - maxWorstCaseDistance, - slowestChainIndexingCursor, - worstCaseDistance, - }), - ); + + const result = buildAmIRealtimeResultOk({ + maxWorstCaseDistance, + slowestChainIndexingCursor, + worstCaseDistance, + }); + + return resultIntoHttpResponse(c, result); }, ); diff --git a/apps/ensapi/src/handlers/registrar-actions-api.ts b/apps/ensapi/src/handlers/registrar-actions-api.ts index 40d3260e0..396cc7e6d 100644 --- a/apps/ensapi/src/handlers/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/registrar-actions-api.ts @@ -3,17 +3,17 @@ import z from "zod/v4"; import { buildPageContext, - buildResultOkTimestamped, + buildRegistrarActionsResultOk, buildResultServiceUnavailable, type Node, RECORDS_PER_PAGE_DEFAULT, RECORDS_PER_PAGE_MAX, type RegistrarActionsFilter, RegistrarActionsOrders, - type RegistrarActionsResult, + type RegistrarActionsServerResult, ResultCodes, registrarActionsFilter, - serializeNamedRegistrarActions, + serializeRegistrarActionsResultOk, } from "@ensnode/ensnode-sdk"; import { makeLowercaseAddressSchema, @@ -150,7 +150,7 @@ async function fetchRegistrarActions( return { registrarActions, pageContext }; } -const routeResponsesDescription = buildRouteResponsesDescription({ +const routeResponsesDescription = buildRouteResponsesDescription({ [ResultCodes.Ok]: { description: "Successfully retrieved registrar actions", }, @@ -201,9 +201,9 @@ app.get( // Get the accurateAsOf timestamp from the slowest chain indexing cursor const accurateAsOf = c.var.indexingStatus.snapshot.slowestChainIndexingCursor; - const result = buildResultOkTimestamped( + const result = buildRegistrarActionsResultOk( { - registrarActions: serializeNamedRegistrarActions(registrarActions), + registrarActions, pageContext, }, accurateAsOf, @@ -292,15 +292,17 @@ app.get( // Get the accurateAsOf timestamp from the slowest chain indexing cursor const accurateAsOf = c.var.indexingStatus.snapshot.slowestChainIndexingCursor; - const result = buildResultOkTimestamped( + const result = buildRegistrarActionsResultOk( { - registrarActions: serializeNamedRegistrarActions(registrarActions), + registrarActions, pageContext, }, accurateAsOf, ); - return resultIntoHttpResponse(c, result); + const serializedResult = serializeRegistrarActionsResultOk(result); + + return resultIntoHttpResponse(c, serializedResult); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; logger.error(errorMessage); diff --git a/apps/ensapi/src/index.ts b/apps/ensapi/src/index.ts index c2bdf97d2..83cefcf97 100644 --- a/apps/ensapi/src/index.ts +++ b/apps/ensapi/src/index.ts @@ -8,9 +8,9 @@ import { html } from "hono/html"; import { openAPIRouteHandler } from "hono-openapi"; import { + buildHealthResultOk, buildResultInternalServerError, buildResultNotFound, - buildResultOk, } from "@ensnode/ensnode-sdk"; import { indexingStatusCache } from "@/cache/indexing-status.cache"; @@ -118,7 +118,7 @@ app.get( // will automatically 503 if config is not available due to ensIndexerPublicConfigMiddleware app.get("/health", async (c) => { - const result = buildResultOk({ message: "fallback ok" }); + const result = buildHealthResultOk("fallback ok"); return resultIntoHttpResponse(c, result); }); @@ -133,7 +133,7 @@ app.notFound((c) => { app.onError((error, ctx) => { logger.error(error); - const result = buildResultInternalServerError("Internal Server Error"); + const result = buildResultInternalServerError(`Internal Server Error: ${error.message}`); return resultIntoHttpResponse(ctx, result); }); diff --git a/apps/ensapi/src/lib/handlers/route-responses-description.ts b/apps/ensapi/src/lib/handlers/route-responses-description.ts index 806446ab1..692ff2fce 100644 --- a/apps/ensapi/src/lib/handlers/route-responses-description.ts +++ b/apps/ensapi/src/lib/handlers/route-responses-description.ts @@ -1,8 +1,11 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"; -import type { ResultServer } from "@ensnode/ensnode-sdk"; +import type { AbstractResult } from "@ensnode/ensnode-sdk"; -import { resultCodeToHttpStatusCode } from "@/lib/result/result-into-http-response"; +import { + resultCodeToHttpStatusCode, + type ServerResultCode, +} from "@/lib/result/result-into-http-response"; interface RouteDescription { description: string; @@ -15,7 +18,7 @@ interface RouteDescription { * @param routes - A record mapping operation result codes to route descriptions * @returns A record mapping HTTP status codes to route descriptions */ -export function buildRouteResponsesDescription( +export function buildRouteResponsesDescription>( routes: Record, ): Record { return Object.entries(routes).reduce( diff --git a/apps/ensapi/src/lib/result/result-into-http-response.test.ts b/apps/ensapi/src/lib/result/result-into-http-response.test.ts index 3786b5360..da89774c2 100644 --- a/apps/ensapi/src/lib/result/result-into-http-response.test.ts +++ b/apps/ensapi/src/lib/result/result-into-http-response.test.ts @@ -6,11 +6,8 @@ import { buildResultInternalServerError, buildResultInvalidRequest, buildResultNotFound, - buildResultOk, buildResultServiceUnavailable, ResultCodes, - type ResultServer, - type ResultServerResultCode, } from "@ensnode/ensnode-sdk"; import { resultCodeToHttpStatusCode, resultIntoHttpResponse } from "./result-into-http-response"; @@ -45,13 +42,6 @@ describe("resultCodeToHttpStatusCode", () => { expect(statusCode).toBe(503); }); - - it("should throw an error for unhandled result code", () => { - const unhandledResultCode = "test" as ResultServerResultCode; - expect(() => resultCodeToHttpStatusCode(unhandledResultCode)).toThrowError( - `Unhandled result code: ${unhandledResultCode}`, - ); - }); }); describe("resultIntoHttpResponse", () => { @@ -66,7 +56,7 @@ describe("resultIntoHttpResponse", () => { }); it("should return HTTP response with status 200 for Ok result", async () => { - const result = buildResultOk("test data"); + const result = { resultCode: ResultCodes.Ok, data: "test data" }; const response = resultIntoHttpResponse(mockContext, result); @@ -113,25 +103,10 @@ describe("resultIntoHttpResponse", () => { it("should handle result with complex data object", async () => { const complexData = { id: 1, name: "Test", attributes: { key: "value" } }; - const result = buildResultOk(complexData); + const result = { resultCode: ResultCodes.Ok, data: complexData }; const response = resultIntoHttpResponse(mockContext, result); expect(response.status).toBe(200); expect(await response.json()).toStrictEqual(result); }); - - it("should handle result with unhandled result code", async () => { - const unhandledResultCode = "test" as ResultServerResultCode; - const result = { - resultCode: unhandledResultCode, - errorMessage: "Unhandled result code", - }; - - const response = resultIntoHttpResponse(mockContext, result as ResultServer); - - expect(response.status).toBe(500); - expect(await response.json()).toStrictEqual( - buildResultInternalServerError("An internal server error occurred."), - ); - }); }); diff --git a/apps/ensapi/src/lib/result/result-into-http-response.ts b/apps/ensapi/src/lib/result/result-into-http-response.ts index fd3b336a6..31007b3c0 100644 --- a/apps/ensapi/src/lib/result/result-into-http-response.ts +++ b/apps/ensapi/src/lib/result/result-into-http-response.ts @@ -1,27 +1,17 @@ import type { Context } from "hono"; import type { ContentfulStatusCode } from "hono/utils/http-status"; -import { - buildResultInternalServerError, - ResultCodes, - type ResultServer, - type ResultServerResultCode, -} from "@ensnode/ensnode-sdk"; +import { type ResultCodeServerError, ResultCodes } from "@ensnode/ensnode-sdk"; -import { makeLogger } from "@/lib/logger"; - -const logger = makeLogger("result-into-http-response"); +export type ServerResultCode = ResultCodeServerError | typeof ResultCodes.Ok; /** * Get HTTP status code corresponding to the given operation result code. * * @param resultCode - The operation result code * @returns Corresponding HTTP status code - * @throws Error if the result code is unhandled */ -export function resultCodeToHttpStatusCode( - resultCode: ResultServerResultCode, -): ContentfulStatusCode { +export function resultCodeToHttpStatusCode(resultCode: ServerResultCode): ContentfulStatusCode { switch (resultCode) { case ResultCodes.Ok: return 200; @@ -33,8 +23,6 @@ export function resultCodeToHttpStatusCode( return 500; case ResultCodes.ServiceUnavailable: return 503; - default: - throw new Error(`Unhandled result code: ${resultCode}`); } } @@ -45,24 +33,13 @@ export function resultCodeToHttpStatusCode( * @param result - The operation result * @returns HTTP response with appropriate status code and JSON body */ -export function resultIntoHttpResponse( +export function resultIntoHttpResponse( c: Context, result: TResult, ): Response { - try { - // Determine HTTP status code from result code - const statusCode = resultCodeToHttpStatusCode(result.resultCode); - - // Return JSON response with appropriate status code - return c.json(result, statusCode); - } catch { - // In case of unhandled result code, log error and - // return response from internal server error result - logger.error(`Unhandled result code encountered: ${result.resultCode}`); + // Determine HTTP status code from result code + const statusCode = resultCodeToHttpStatusCode(result.resultCode); - return resultIntoHttpResponse( - c, - buildResultInternalServerError("An internal server error occurred."), - ); - } + // Return JSON response with appropriate status code + return c.json(result, statusCode); } diff --git a/apps/ensapi/src/middleware/registrar-actions.middleware.ts b/apps/ensapi/src/middleware/registrar-actions.middleware.ts index a4b6a037b..dfcaebdc1 100644 --- a/apps/ensapi/src/middleware/registrar-actions.middleware.ts +++ b/apps/ensapi/src/middleware/registrar-actions.middleware.ts @@ -48,9 +48,14 @@ export const registrarActionsApiMiddleware = factory.createMiddleware( } if (!registrarActionsPrerequisites.hasEnsIndexerConfigSupport(config.ensIndexerPublicConfig)) { - const result = buildResultServiceUnavailable(`Registrar Actions API is not available`, { - details: `Connected ENSIndexer must have all following plugins active: ${registrarActionsPrerequisites.requiredPlugins.join(", ")}`, - }); + const errorMessage = [ + `Registrar Actions API is not available.`, + `Connected ENSIndexer configuration does not have all required plugins active.`, + `Current plugins: "${config.ensIndexerPublicConfig.plugins.join(", ")}".`, + `Required plugins: "${registrarActionsPrerequisites.requiredPlugins.join(", ")}".`, + ].join(" "); + + const result = buildResultServiceUnavailable(errorMessage); return resultIntoHttpResponse(c, result); } @@ -62,9 +67,12 @@ export const registrarActionsApiMiddleware = factory.createMiddleware( `Registrar Actions API requested but indexing status is not available in context.`, ); - const result = buildResultServiceUnavailable(`Registrar Actions API is not available`, { - details: `Indexing status is currently unavailable to this ENSApi instance.`, - }); + const errorMessage = [ + `Registrar Actions API is not available.`, + `Indexing status is currently unavailable to this ENSApi instance.`, + ].join(" "); + + const result = buildResultServiceUnavailable(errorMessage); return resultIntoHttpResponse(c, result); } @@ -74,9 +82,14 @@ export const registrarActionsApiMiddleware = factory.createMiddleware( if ( !registrarActionsPrerequisites.hasIndexingStatusSupport(omnichainSnapshot.omnichainStatus) ) { - const result = buildResultServiceUnavailable(`Registrar Actions API is not available`, { - details: `The cached omnichain indexing status of the Connected ENSIndexer must be one of the following ${registrarActionsPrerequisites.supportedIndexingStatusIds.map((statusId) => `"${statusId}"`).join(", ")}.`, - }); + const errorMessage = [ + `Registrar Actions API is not available.`, + `The cached omnichain indexing status of the connected ENSIndexer is not supported.`, + `Currently indexing status: "${omnichainSnapshot.omnichainStatus}".`, + `Supported indexing statuses: [${registrarActionsPrerequisites.supportedIndexingStatusIds.map((statusId) => `"${statusId}"`).join(", ")}].`, + ].join(" "); + + const result = buildResultServiceUnavailable(errorMessage); return resultIntoHttpResponse(c, result); } diff --git a/packages/ensnode-sdk/src/api/amirealtime/result.ts b/packages/ensnode-sdk/src/api/amirealtime/result.ts index 0088accec..a08230b7d 100644 --- a/packages/ensnode-sdk/src/api/amirealtime/result.ts +++ b/packages/ensnode-sdk/src/api/amirealtime/result.ts @@ -1,13 +1,17 @@ import type { RealtimeIndexingStatusProjection } from "../../ensindexer"; -import type { - Duration, - ResultInternalServerError, - ResultInvalidRequest, - ResultServerOk, - ResultServiceUnavailable, - UnixTimestamp, +import { + type AbstractResultOk, + type Duration, + ResultCodes, + type ResultInternalServerError, + type ResultInvalidRequest, + type ResultServiceUnavailable, + type UnixTimestamp, } from "../../shared"; +/** + * Successful result data for "Am I Realtime?" API requests. + */ export interface AmIRealtimeResultOkData { /** * Represents the maximum worst-case distance from the current "tip" of @@ -34,14 +38,26 @@ export interface AmIRealtimeResultOkData { slowestChainIndexingCursor: UnixTimestamp; } +/** + * Successful result for "Am I Realtime?" API requests. + */ +export type AmIRealtimeResultOk = AbstractResultOk; + +export function buildAmIRealtimeResultOk(data: AmIRealtimeResultOkData): AmIRealtimeResultOk { + return { + resultCode: ResultCodes.Ok, + data, + }; +} + /** * The operation result for "Am I Realtime?" API requests. * * Use the `resultCode` field to determine the specific type interpretation * at runtime. */ -export type AmIRealtimeResult = - | ResultServerOk +export type AmIRealtimeServerResult = + | AmIRealtimeResultOk | ResultInvalidRequest | ResultInternalServerError | ResultServiceUnavailable; diff --git a/packages/ensnode-sdk/src/api/health/index.ts b/packages/ensnode-sdk/src/api/health/index.ts new file mode 100644 index 000000000..bb162e4c8 --- /dev/null +++ b/packages/ensnode-sdk/src/api/health/index.ts @@ -0,0 +1 @@ +export * from "./result"; diff --git a/packages/ensnode-sdk/src/api/health/result.ts b/packages/ensnode-sdk/src/api/health/result.ts new file mode 100644 index 000000000..d31944830 --- /dev/null +++ b/packages/ensnode-sdk/src/api/health/result.ts @@ -0,0 +1,23 @@ +import { type AbstractResultOk, ResultCodes } from "../../shared"; + +/** + * Successful result data for Health API requests. + */ +export type HealthResultOkData = string; + +/** + * Successful result for Health API requests. + */ +export type HealthResultOk = AbstractResultOk; + +export function buildHealthResultOk(data: HealthResultOkData): HealthResultOk { + return { + resultCode: ResultCodes.Ok, + data, + }; +} + +/** + * The operation result for "Am I Realtime?" API requests. + */ +export type HealthServerResult = HealthResultOk; diff --git a/packages/ensnode-sdk/src/api/index.ts b/packages/ensnode-sdk/src/api/index.ts index e2b3c40d4..0f8868143 100644 --- a/packages/ensnode-sdk/src/api/index.ts +++ b/packages/ensnode-sdk/src/api/index.ts @@ -1,5 +1,6 @@ export * from "./amirealtime"; export * from "./config"; +export * from "./health"; export * from "./indexing-status"; export * from "./name-tokens"; export * from "./registrar-actions"; diff --git a/packages/ensnode-sdk/src/api/registrar-actions/result.ts b/packages/ensnode-sdk/src/api/registrar-actions/result.ts index 7993233f4..bb8732384 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/result.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/result.ts @@ -1,11 +1,15 @@ -import type { - ResultInternalServerError, - ResultInvalidRequest, - ResultServerOk, - ResultServiceUnavailable, +import { + type AbstractResultOkTimestamped, + ResultCodes, + type ResultInternalServerError, + type ResultInvalidRequest, + type ResultServiceUnavailable, + type UnixTimestamp, } from "../../shared"; import type { ResponsePageContext } from "../shared"; import type { NamedRegistrarAction } from "../types"; +import { serializeNamedRegistrarAction } from "./serialize"; +import type { SerializedNamedRegistrarAction } from "./serialized-response"; export interface RegistrarActionsResultOkData { /** @@ -20,13 +24,65 @@ export interface RegistrarActionsResultOkData { } /** - * The operation result for Registrar Actions API requests. + * Serialized representation of {@link RegistrarActionsResultOkData}. + */ +export interface SerializedRegistrarActionsResultOkData + extends Omit { + registrarActions: SerializedNamedRegistrarAction[]; +} + +/** + * Successful result for Registrar Actions API requests. + */ +export type RegistrarActionsResultOk = AbstractResultOkTimestamped; + +/** + * Serialized representation of {@link RegistrarActionsResultOk}. + */ +export type SerializedRegistrarActionsResultOk = + AbstractResultOkTimestamped; + +/** + * Builds a successful result for Registrar Actions API requests. + * + * @param data - The data for the successful result + * @param minIndexingCursor - The minimum indexing cursor timestamp + * @returns + */ +export function buildRegistrarActionsResultOk( + data: RegistrarActionsResultOkData, + minIndexingCursor: UnixTimestamp, +): RegistrarActionsResultOk { + return { + resultCode: ResultCodes.Ok, + data, + minIndexingCursor, + }; +} + +/** + * Serializes a {@link RegistrarActionsResultOk} into a {@link SerializedRegistrarActionsResultOk}. + */ +export function serializeRegistrarActionsResultOk( + result: RegistrarActionsResultOk, +): SerializedRegistrarActionsResultOk { + return { + ...result, + data: { + ...result.data, + registrarActions: result.data.registrarActions.map(serializeNamedRegistrarAction), + }, + }; +} + +/** + * The server operation result for Registrar Actions API. * * Use the `resultCode` field to determine the specific type interpretation * at runtime. */ -export type RegistrarActionsResult = - | ResultServerOk +export type RegistrarActionsServerResult = + | RegistrarActionsResultOk | ResultInvalidRequest | ResultInternalServerError | ResultServiceUnavailable; diff --git a/packages/ensnode-sdk/src/shared/result/result-code.ts b/packages/ensnode-sdk/src/shared/result/result-code.ts index e100590dc..9f1630dd7 100644 --- a/packages/ensnode-sdk/src/shared/result/result-code.ts +++ b/packages/ensnode-sdk/src/shared/result/result-code.ts @@ -13,12 +13,14 @@ export const ResultCodes = { Ok: "ok", /** - * Server error: the operation failed due to an unexpected error internally within the server. + * Server error: the operation failed due to an unexpected error internally + * within the server. */ InternalServerError: "internal-server-error", /** - * Server error: the operation failed due to the service being unavailable at the time. + * Server error: the operation failed due to the requested service being + * unavailable at the time of the request. */ ServiceUnavailable: "service-unavailable", @@ -43,7 +45,8 @@ export const ResultCodes = { RequestTimeout: "request-timeout", /** - * Client error: received an unrecognized result from the server for an operation. + * Client error: received an unrecognized result from the server for + * an operation. */ ClientUnrecognizedOperationResult: "client-unrecognized-operation-result", } as const; @@ -68,7 +71,8 @@ export const RESULT_CODE_CLIENT_ERROR_CODES = [ ] as const; /** - * List of all error codes the client can return (client-originated + relayed from server). + * List of all error codes the client can return + * (client-originated + relayed from server). */ export const RESULT_CODE_ALL_ERROR_CODES = [ ...RESULT_CODE_CLIENT_ERROR_CODES, diff --git a/packages/ensnode-sdk/src/shared/result/result-common.ts b/packages/ensnode-sdk/src/shared/result/result-common.ts index cdf0c1876..26ad047c1 100644 --- a/packages/ensnode-sdk/src/shared/result/result-common.ts +++ b/packages/ensnode-sdk/src/shared/result/result-common.ts @@ -1,67 +1,30 @@ /** * Common Result Types and Builders * - * This module defines common result types and builder functions for - * standardizing operation results across the SDK. It includes types and - * builders for successful results as well as various error scenarios. + * This module defines specific result data models that might be shared across more than 1 route. */ -import type { UnixTimestamp } from "../../shared"; -import type { - AbstractResultError, - AbstractResultOk, - AbstractResultOkTimestamped, -} from "./result-base"; +import type { AbstractResultError } from "./result-base"; import { type ResultCode, ResultCodes } from "./result-code"; -/************************************************************ - * Result OK - ************************************************************/ - -/** - * Builds a result object representing a successful operation. - */ -export function buildResultOk(data: TData): AbstractResultOk { - return { - resultCode: ResultCodes.Ok, - data, - }; -} - -/** - * Builds a result object representing a successful operation - * with data guaranteed to be at least up to a certain timestamp. - */ -export function buildResultOkTimestamped( - data: TData, - minIndexingCursor: UnixTimestamp, -): AbstractResultOkTimestamped { - return { - ...buildResultOk(data), - minIndexingCursor, - }; -} - /************************************************************ * Service Unavailable ************************************************************/ export interface ResultServiceUnavailable - extends AbstractResultError {} + extends AbstractResultError {} /** * Builds a result object representing a service unavailable error. */ export const buildResultServiceUnavailable = ( errorMessage?: string, - data?: { details?: string }, suggestRetry: boolean = true, ): ResultServiceUnavailable => { return { resultCode: ResultCodes.ServiceUnavailable, errorMessage: errorMessage ?? "The service is currently unavailable.", suggestRetry, - ...(data ? { data } : {}), }; }; @@ -230,16 +193,6 @@ export const isRecognizedResultCodeForOperation = ( return recognizedResultCodesForOperation.includes(resultCode as ResultCode); }; -/************************************************************ - * All common server errors - ************************************************************/ - -export type ResultServerError = - | ResultInvalidRequest - | ResultNotFound - | ResultInternalServerError - | ResultServiceUnavailable; - /************************************************************ * All common client errors ************************************************************/ @@ -248,22 +201,3 @@ export type ResultClientError = | ResultConnectionError | ResultRequestTimeout | ResultClientUnrecognizedOperationResult; - -/************************************************************ - * Server Operation Result Types - ************************************************************/ - -/** - * Type representing a successful server operation result. - */ -export type ResultServerOk = AbstractResultOk | AbstractResultOkTimestamped; - -/** - * Union type representing all possible server operation results. - */ -export type ResultServer = ResultServerOk | ResultServerError; - -/** - * Type representing all possible server operation result codes. - */ -export type ResultServerResultCode = ResultServer["resultCode"]; From 65650975e0017a052b9ada95a8c93e7fdd77fd9a Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 21 Jan 2026 17:56:19 +0100 Subject: [PATCH 12/23] feat(ensnode-sdk): introduce `InsufficientIndexingProgress` result code --- .../result/result-into-http-response.test.ts | 19 ++++ .../lib/result/result-into-http-response.ts | 1 + packages/ensnode-sdk/src/internal.ts | 1 + .../src/shared/result/result-code.ts | 6 ++ .../src/shared/result/result-common.ts | 62 ++++++++++++ .../src/shared/result/zod-schemas.ts | 99 +++++++++++++++++++ 6 files changed, 188 insertions(+) create mode 100644 packages/ensnode-sdk/src/shared/result/zod-schemas.ts diff --git a/apps/ensapi/src/lib/result/result-into-http-response.test.ts b/apps/ensapi/src/lib/result/result-into-http-response.test.ts index da89774c2..cb477cb29 100644 --- a/apps/ensapi/src/lib/result/result-into-http-response.test.ts +++ b/apps/ensapi/src/lib/result/result-into-http-response.test.ts @@ -3,6 +3,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { + buildResultInsufficientIndexingProgress, buildResultInternalServerError, buildResultInvalidRequest, buildResultNotFound, @@ -101,6 +102,24 @@ describe("resultIntoHttpResponse", () => { expect(responseJson).toStrictEqual(result); }); + it("should return HTTP response with status 503 for InsufficientIndexingProgress result", async () => { + const result = buildResultInsufficientIndexingProgress("Insufficient indexing progress", { + indexingStatus: "omnichain-backfill", + slowestChainIndexingCursor: 1620003600, + earliestIndexingCursor: 1620000000, + progressSufficientFrom: { + indexingStatus: "realtime", + indexingCursor: 1620007200, + }, + }); + + const response = resultIntoHttpResponse(mockContext, result); + + expect(response.status).toBe(503); + const responseJson = await response.json(); + expect(responseJson).toStrictEqual(result); + }); + it("should handle result with complex data object", async () => { const complexData = { id: 1, name: "Test", attributes: { key: "value" } }; const result = { resultCode: ResultCodes.Ok, data: complexData }; diff --git a/apps/ensapi/src/lib/result/result-into-http-response.ts b/apps/ensapi/src/lib/result/result-into-http-response.ts index 31007b3c0..f24c1f87c 100644 --- a/apps/ensapi/src/lib/result/result-into-http-response.ts +++ b/apps/ensapi/src/lib/result/result-into-http-response.ts @@ -22,6 +22,7 @@ export function resultCodeToHttpStatusCode(resultCode: ServerResultCode): Conten case ResultCodes.InternalServerError: return 500; case ResultCodes.ServiceUnavailable: + case ResultCodes.InsufficientIndexingProgress: return 503; } } diff --git a/packages/ensnode-sdk/src/internal.ts b/packages/ensnode-sdk/src/internal.ts index 95eec93ca..69dff3369 100644 --- a/packages/ensnode-sdk/src/internal.ts +++ b/packages/ensnode-sdk/src/internal.ts @@ -39,6 +39,7 @@ export * from "./shared/log-level"; export * from "./shared/protocol-acceleration/is-bridged-resolver"; export * from "./shared/protocol-acceleration/is-ensip-19-reverse-resolver"; export * from "./shared/protocol-acceleration/is-static-resolver"; +export * from "./shared/result/zod-schemas"; export * from "./shared/thegraph"; export * from "./shared/zod-schemas"; export * from "./shared/zod-types"; diff --git a/packages/ensnode-sdk/src/shared/result/result-code.ts b/packages/ensnode-sdk/src/shared/result/result-code.ts index 9f1630dd7..a77aa81b2 100644 --- a/packages/ensnode-sdk/src/shared/result/result-code.ts +++ b/packages/ensnode-sdk/src/shared/result/result-code.ts @@ -24,6 +24,11 @@ export const ResultCodes = { */ ServiceUnavailable: "service-unavailable", + /** + * Server error: the operation failed due to insufficient indexing progress. + */ + InsufficientIndexingProgress: "insufficient-indexing-progress", + /** * Server error: the requested resource was not found. */ @@ -57,6 +62,7 @@ export const ResultCodes = { export const RESULT_CODE_SERVER_ERROR_CODES = [ ResultCodes.InternalServerError, ResultCodes.ServiceUnavailable, + ResultCodes.InsufficientIndexingProgress, ResultCodes.NotFound, ResultCodes.InvalidRequest, ] as const; diff --git a/packages/ensnode-sdk/src/shared/result/result-common.ts b/packages/ensnode-sdk/src/shared/result/result-common.ts index 26ad047c1..edaa523ed 100644 --- a/packages/ensnode-sdk/src/shared/result/result-common.ts +++ b/packages/ensnode-sdk/src/shared/result/result-common.ts @@ -4,6 +4,7 @@ * This module defines specific result data models that might be shared across more than 1 route. */ +import type { UnixTimestamp } from "../types"; import type { AbstractResultError } from "./result-base"; import { type ResultCode, ResultCodes } from "./result-code"; @@ -28,6 +29,67 @@ export const buildResultServiceUnavailable = ( }; }; +/************************************************************ + * Insufficient Indexing Progress + ************************************************************/ + +/** + * Data for insufficient indexing progress error result. + */ +export interface ResultInsufficientIndexingProgressData { + /** + * The current omnichain indexing status. + */ + indexingStatus: string; + + /** + * The timestamp of the "slowest" latest indexed block timestamp + * across all indexed chains. + */ + slowestChainIndexingCursor: UnixTimestamp; + + /** + * The timestamp of the earliest indexed block across all indexed chains. + */ + earliestIndexingCursor: UnixTimestamp; + + /** + * Information about when indexing progress is considered sufficient. + */ + progressSufficientFrom: { + /** + * The required omnichain indexing status for sufficient progress. + */ + indexingStatus: string; + + /** + * The timestamp from which indexing progress is considered sufficient. + */ + indexingCursor: UnixTimestamp; + }; +} + +export interface ResultInsufficientIndexingProgress + extends AbstractResultError< + typeof ResultCodes.InsufficientIndexingProgress, + ResultInsufficientIndexingProgressData + > {} + +/** + * Builds a result object representing a insufficient indexing progress error. + */ +export const buildResultInsufficientIndexingProgress = ( + errorMessage: string, + data: ResultInsufficientIndexingProgressData, +): ResultInsufficientIndexingProgress => { + return { + resultCode: ResultCodes.InsufficientIndexingProgress, + suggestRetry: true, + errorMessage, + data, + }; +}; + /************************************************************ * Internal Server Error ************************************************************/ diff --git a/packages/ensnode-sdk/src/shared/result/zod-schemas.ts b/packages/ensnode-sdk/src/shared/result/zod-schemas.ts new file mode 100644 index 000000000..b704e2c73 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/zod-schemas.ts @@ -0,0 +1,99 @@ +import z from "zod/v4"; + +import { makeUnixTimestampSchema } from "../zod-schemas"; +import { type ResultCode, ResultCodes } from "./result-code"; +import { + ResultInsufficientIndexingProgress, + ResultInsufficientIndexingProgressData, + ResultInternalServerError, + ResultInvalidRequest, + ResultServiceUnavailable, +} from "./result-common"; + +/** + * Build a schema for a successful result with the given data schema. + */ +export function buildAbstractResultOkSchema(dataSchema: z.ZodType) { + return z.object({ + resultCode: z.literal(ResultCodes.Ok), + data: dataSchema, + }); +} + +/** + * Build a schema for a successful result with timestamped data. + */ +export function buildAbstractResultOkTimestampedSchema(dataSchema: z.ZodType) { + return z.object({ + resultCode: z.literal(ResultCodes.Ok), + data: dataSchema, + minIndexingCursor: makeUnixTimestampSchema("minIndexingCursor"), + }); +} + +/** + * Build a schema for an error result with the given result code. + */ +export function buildAbstractResultErrorSchema( + resultCode: TResultCode, +) { + return z.object({ + resultCode: z.literal(resultCode), + errorMessage: z.string(), + }); +} +/** + * Build a schema for an error result with the given result code and data schema. + */ +export function buildAbstractResultErrorWithDataSchema( + resultCode: TResultCode, + dataSchema: z.ZodType, +) { + return z.object({ + resultCode: z.literal(resultCode), + errorMessage: z.string(), + data: dataSchema, + }); +} + +/** + * Schema for {@link ResultInvalidRequest}. + */ +export const resultErrorInvalidRequestSchema = buildAbstractResultErrorSchema( + ResultCodes.InvalidRequest, +); + +/** + * Schema for {@link ResultInternalServerError}. + */ +export const resultErrorInternalServerErrorSchema = buildAbstractResultErrorSchema( + ResultCodes.InternalServerError, +); + +/** + * Schema for {@link ResultServiceUnavailable}. + */ +export const resultErrorServiceUnavailableSchema = buildAbstractResultErrorSchema( + ResultCodes.ServiceUnavailable, +); + +/** + * Schema for {@link ResultInsufficientIndexingProgressData}. + */ +export const insufficientIndexingProgressDataSchema = z.object({ + indexingStatus: z.string(), + slowestChainIndexingCursor: makeUnixTimestampSchema("slowestChainIndexingCursor"), + earliestChainIndexingCursor: makeUnixTimestampSchema("earliestChainIndexingCursor"), + progressSufficientFrom: z.object({ + indexingStatus: z.string(), + indexingCursor: makeUnixTimestampSchema("progressSufficientFrom.indexingCursor"), + }), +}); + +/** + * Schema for {@link ResultInsufficientIndexingProgress}. + */ +export const resultErrorInsufficientIndexingProgressSchema = buildAbstractResultErrorWithDataSchema( + ResultCodes.InsufficientIndexingProgress, + insufficientIndexingProgressDataSchema, +); From 159d21070374f3328fca3697538a5ab248425eac Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 21 Jan 2026 17:59:34 +0100 Subject: [PATCH 13/23] feat(ensapi): update OpenAPI route descriptions Also, apply `InsufficientIndexingProgress` result code in Registrar Action API and "Am I Realtime?" API. --- .../src/handlers/amirealtime-api.test.ts | 47 +++++- apps/ensapi/src/handlers/amirealtime-api.ts | 144 ++++++++++++++++-- .../src/handlers/registrar-actions-api.ts | 86 +++++++++-- .../handlers/route-responses-description.ts | 34 ----- .../registrar-actions.middleware.ts | 40 ++++- .../src/api/amirealtime/zod-schemas.ts | 16 ++ packages/ensnode-sdk/src/api/health/result.ts | 2 +- .../api/registrar-actions/prerequisites.ts | 44 ++++-- .../src/api/registrar-actions/result.ts | 5 +- .../api/registrar-actions/zod-schemas.test.ts | 3 +- .../shared/pagination/build-page-context.ts | 2 - .../src/api/shared/pagination/response.ts | 10 -- .../src/api/shared/pagination/zod-schemas.ts | 4 +- .../indexing-status/helpers.test.ts | 89 +++++++++++ .../src/ensindexer/indexing-status/helpers.ts | 25 +++ packages/ensnode-sdk/src/internal.ts | 1 + .../ensnode-sdk/src/registrars/zod-schemas.ts | 41 +++-- .../ensnode-sdk/src/shared/zod-schemas.ts | 62 ++++++-- 18 files changed, 519 insertions(+), 136 deletions(-) delete mode 100644 apps/ensapi/src/lib/handlers/route-responses-description.ts create mode 100644 packages/ensnode-sdk/src/api/amirealtime/zod-schemas.ts diff --git a/apps/ensapi/src/handlers/amirealtime-api.test.ts b/apps/ensapi/src/handlers/amirealtime-api.test.ts index 4c76f25be..2174d09b4 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.test.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.test.ts @@ -2,8 +2,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildAmIRealtimeResultOk, + ChainIndexingConfigTypeIds, + ChainIndexingStatusIds, + type ChainIndexingStatusSnapshotFollowing, type CrossChainIndexingStatusSnapshot, createRealtimeIndexingStatusProjection, + OmnichainIndexingStatusIds, type UnixTimestamp, } from "@ensnode/ensnode-sdk"; @@ -26,15 +30,42 @@ describe("amirealtime-api", () => { slowestChainIndexingCursor, }: { now: UnixTimestamp; - slowestChainIndexingCursor?: UnixTimestamp; + slowestChainIndexingCursor: UnixTimestamp; }) => { indexingStatusMiddlewareMock.mockImplementation(async (c, next) => { const indexingStatus = { - slowestChainIndexingCursor: slowestChainIndexingCursor ?? now - 10, + omnichainSnapshot: { + omnichainStatus: OmnichainIndexingStatusIds.Following, + omnichainIndexingCursor: slowestChainIndexingCursor, + chains: new Map([ + [ + 1, + { + chainStatus: ChainIndexingStatusIds.Following, + latestIndexedBlock: { + timestamp: now - 10, + number: 150, + }, + latestKnownBlock: { + timestamp: now, + number: 151, + }, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: { + number: 100, + timestamp: now - 1000, + }, + }, + } satisfies ChainIndexingStatusSnapshotFollowing, + ], + ]), + } as CrossChainIndexingStatusSnapshot["omnichainSnapshot"], snapshotTime: now, + slowestChainIndexingCursor, } satisfies Pick< CrossChainIndexingStatusSnapshot, - "slowestChainIndexingCursor" | "snapshotTime" + "omnichainSnapshot" | "slowestChainIndexingCursor" | "snapshotTime" > as CrossChainIndexingStatusSnapshot; const realtimeProjection = createRealtimeIndexingStatusProjection(indexingStatus, now); @@ -62,7 +93,7 @@ describe("amirealtime-api", () => { describe("request", () => { it("should accept valid maxWorstCaseDistance query param", async () => { // Arrange: set `indexingStatus` context var - arrangeMockedIndexingStatusMiddleware({ now }); + arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 10 }); // Act const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=300"); @@ -100,7 +131,7 @@ describe("amirealtime-api", () => { it("should use default maxWorstCaseDistance when unset", async () => { // Arrange: set `indexingStatus` context var - arrangeMockedIndexingStatusMiddleware({ now }); + arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 10 }); // Act const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance"); @@ -119,7 +150,7 @@ describe("amirealtime-api", () => { it("should use default maxWorstCaseDistance when not provided", async () => { // Arrange: set `indexingStatus` context var - arrangeMockedIndexingStatusMiddleware({ now }); + arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 10 }); // Act const response = await app.request("http://localhost/amirealtime"); @@ -138,7 +169,7 @@ describe("amirealtime-api", () => { it("should reject invalid maxWorstCaseDistance (negative number)", async () => { // Arrange: set `indexingStatus` context var - arrangeMockedIndexingStatusMiddleware({ now }); + arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 10 }); // Act const response = await app.request("http://localhost/amirealtime?maxWorstCaseDistance=-1"); @@ -152,7 +183,7 @@ describe("amirealtime-api", () => { it("should reject invalid maxWorstCaseDistance (not a number)", async () => { // Arrange: set `indexingStatus` context var - arrangeMockedIndexingStatusMiddleware({ now }); + arrangeMockedIndexingStatusMiddleware({ now, slowestChainIndexingCursor: now - 10 }); // Act const response = await app.request( diff --git a/apps/ensapi/src/handlers/amirealtime-api.ts b/apps/ensapi/src/handlers/amirealtime-api.ts index fedd415a4..6dfa2c799 100644 --- a/apps/ensapi/src/handlers/amirealtime-api.ts +++ b/apps/ensapi/src/handlers/amirealtime-api.ts @@ -1,19 +1,28 @@ import { minutesToSeconds } from "date-fns"; -import { describeRoute } from "hono-openapi"; +import { describeRoute, resolver as responseSchemaResolver } from "hono-openapi"; import z from "zod/v4"; import { - type AmIRealtimeServerResult, buildAmIRealtimeResultOk, + buildResultInsufficientIndexingProgress, buildResultInternalServerError, buildResultServiceUnavailable, type Duration, + getTimestampForHighestOmnichainKnownBlock, + getTimestampForLowestOmnichainStartBlock, + OmnichainIndexingStatusIds, ResultCodes, } from "@ensnode/ensnode-sdk"; -import { makeDurationSchema } from "@ensnode/ensnode-sdk/internal"; +import { + amIRealtimeResultOkSchema, + makeDurationSchema, + resultErrorInsufficientIndexingProgressSchema, + resultErrorInternalServerErrorSchema, + resultErrorInvalidRequestSchema, + resultErrorServiceUnavailableSchema, +} from "@ensnode/ensnode-sdk/internal"; import { params } from "@/lib/handlers/params.schema"; -import { buildRouteResponsesDescription } from "@/lib/handlers/route-responses-description"; import { validate } from "@/lib/handlers/validate"; import { factory } from "@/lib/hono-factory"; import { resultIntoHttpResponse } from "@/lib/result/result-into-http-response"; @@ -32,22 +41,120 @@ app.get( summary: "Check indexing progress", description: "Checks if the indexing progress is guaranteed to be within a requested worst-case distance of realtime", - responses: buildRouteResponsesDescription({ - [ResultCodes.Ok]: { + responses: { + 200: { description: "Indexing progress is guaranteed to be within the requested distance of realtime", + + content: { + "application/json": { + schema: responseSchemaResolver(amIRealtimeResultOkSchema), + examples: { + [ResultCodes.Ok]: { + summary: '"Am I Realtime?" API indexing progress is within requested distance', + value: buildAmIRealtimeResultOk({ + maxWorstCaseDistance: 12, + slowestChainIndexingCursor: 1768999701, + worstCaseDistance: 9, + }), + description: + "The connected ENSIndexer has sufficient omnichain indexing progress to serve this request.", + }, + }, + }, + }, }, - [ResultCodes.InvalidRequest]: { + 400: { description: "Invalid request parameters", + content: { + "application/json": { + schema: responseSchemaResolver(resultErrorInvalidRequestSchema), + examples: { + [ResultCodes.InvalidRequest]: { + summary: '"Am I Realtime?" API invalid request', + value: { + resultCode: ResultCodes.InvalidRequest, + errorMessage: + "maxWorstCaseDistance query param must be a non-negative integer (>=0)", + }, + description: + "The provided `maxWorstCaseDistance` query parameter is not a non-negative integer.", + }, + }, + }, + }, }, - [ResultCodes.InternalServerError]: { + 500: { description: "Indexing progress cannot be determined due to an internal server error", + content: { + "application/json": { + schema: responseSchemaResolver(resultErrorInternalServerErrorSchema), + examples: { + [ResultCodes.InternalServerError]: { + summary: '"Am I Realtime?" API internal server error', + value: buildResultInternalServerError( + '"Am I Realtime?" API is currently experiencing an internal server error.', + ), + description: "External service or dependency is unavailable.", + }, + [ResultCodes.InsufficientIndexingProgress]: { + summary: '"Am I Realtime?" API has insufficient indexing progress', + value: buildResultInsufficientIndexingProgress( + "Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = 12; maxWorstCaseDistance = 10", + { + indexingStatus: "omnichain-following", + slowestChainIndexingCursor: 1768998722, + earliestIndexingCursor: 1489165544, + progressSufficientFrom: { + indexingStatus: "omnichain-following", + indexingCursor: 1768998731, + }, + }, + ), + }, + }, + }, + }, }, - [ResultCodes.ServiceUnavailable]: { + 503: { description: "Indexing progress is not guaranteed to be within the requested distance of realtime or indexing status unavailable", + content: { + "application/json": { + schema: responseSchemaResolver( + z.discriminatedUnion("resultCode", [ + resultErrorServiceUnavailableSchema, + resultErrorInsufficientIndexingProgressSchema, + ]), + ), + examples: { + [ResultCodes.ServiceUnavailable]: { + summary: '"Am I Realtime?" API is unavailable', + value: buildResultServiceUnavailable( + '"Am I Realtime?" API is currently unavailable.', + ), + description: "External service or dependency is unavailable.", + }, + [ResultCodes.InsufficientIndexingProgress]: { + summary: '"Am I Realtime?" API has insufficient indexing progress', + value: buildResultInsufficientIndexingProgress( + "Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = 12; maxWorstCaseDistance = 10", + { + indexingStatus: "omnichain-following", + slowestChainIndexingCursor: 1768998722, + earliestIndexingCursor: 1489165544, + progressSufficientFrom: { + indexingStatus: "omnichain-following", + indexingCursor: 1768998731, + }, + }, + ), + }, + }, + }, + }, }, - }), + }, }), validate( "query", @@ -78,12 +185,25 @@ app.get( const { maxWorstCaseDistance } = c.req.valid("query"); const { worstCaseDistance, snapshot } = c.var.indexingStatus; - const { slowestChainIndexingCursor } = snapshot; + const { slowestChainIndexingCursor, omnichainSnapshot } = snapshot; + const chains = Array.from(omnichainSnapshot.chains.values()); // Case: worst-case distance exceeds requested maximum if (worstCaseDistance > maxWorstCaseDistance) { - const result = buildResultServiceUnavailable( + const earliestIndexingCursor = getTimestampForLowestOmnichainStartBlock(chains); + const latestIndexingCursor = getTimestampForHighestOmnichainKnownBlock(chains); + + const result = buildResultInsufficientIndexingProgress( `Indexing Status 'worstCaseDistance' must be below or equal to the requested 'maxWorstCaseDistance'; worstCaseDistance = ${worstCaseDistance}; maxWorstCaseDistance = ${maxWorstCaseDistance}`, + { + indexingStatus: omnichainSnapshot.omnichainStatus, + slowestChainIndexingCursor, + earliestIndexingCursor, + progressSufficientFrom: { + indexingStatus: OmnichainIndexingStatusIds.Following, + indexingCursor: latestIndexingCursor, + }, + }, ); return resultIntoHttpResponse(c, result); diff --git a/apps/ensapi/src/handlers/registrar-actions-api.ts b/apps/ensapi/src/handlers/registrar-actions-api.ts index 396cc7e6d..71d16db4c 100644 --- a/apps/ensapi/src/handlers/registrar-actions-api.ts +++ b/apps/ensapi/src/handlers/registrar-actions-api.ts @@ -1,16 +1,16 @@ -import { describeRoute } from "hono-openapi"; +import { describeRoute, resolver as responseSchemaResolver } from "hono-openapi"; import z from "zod/v4"; import { buildPageContext, buildRegistrarActionsResultOk, + buildResultInsufficientIndexingProgress, buildResultServiceUnavailable, type Node, RECORDS_PER_PAGE_DEFAULT, RECORDS_PER_PAGE_MAX, type RegistrarActionsFilter, RegistrarActionsOrders, - type RegistrarActionsServerResult, ResultCodes, registrarActionsFilter, serializeRegistrarActionsResultOk, @@ -20,10 +20,11 @@ import { makeNodeSchema, makePositiveIntegerSchema, makeUnixTimestampSchema, + resultErrorInsufficientIndexingProgressSchema, + resultErrorServiceUnavailableSchema, } from "@ensnode/ensnode-sdk/internal"; import { params } from "@/lib/handlers/params.schema"; -import { buildRouteResponsesDescription } from "@/lib/handlers/route-responses-description"; import { validate } from "@/lib/handlers/validate"; import { factory } from "@/lib/hono-factory"; import { makeLogger } from "@/lib/logger"; @@ -150,20 +151,71 @@ async function fetchRegistrarActions( return { registrarActions, pageContext }; } -const routeResponsesDescription = buildRouteResponsesDescription({ - [ResultCodes.Ok]: { +const routeResponsesDescription = { + 200: { description: "Successfully retrieved registrar actions", + content: { + "application/json": { + schema: responseSchemaResolver(z.object()), + examples: { + [ResultCodes.Ok]: { + summary: "Successfully retrieved registrar actions", + value: serializeRegistrarActionsResultOk( + buildRegistrarActionsResultOk( + { + registrarActions: [], + pageContext: buildPageContext(1, 10, 0), + }, + 1700000000, + ), + ), + }, + }, + }, + }, }, - [ResultCodes.InvalidRequest]: { + 400: { description: "Invalid request parameters", }, - [ResultCodes.ServiceUnavailable]: { - description: "Registrar Actions API is unavailable at the moment", - }, - [ResultCodes.InternalServerError]: { + 500: { description: "An internal server error occurred", }, -}); + 503: { + description: "Registrar Actions API is unavailable", + content: { + "application/json": { + schema: responseSchemaResolver( + z.discriminatedUnion("resultCode", [ + resultErrorServiceUnavailableSchema, + resultErrorInsufficientIndexingProgressSchema, + ]), + ), + examples: { + [ResultCodes.ServiceUnavailable]: { + summary: "Registrar Actions API is unavailable", + value: buildResultServiceUnavailable("Registrar Actions API is currently unavailable."), + description: "External service or dependency is unavailable.", + }, + [ResultCodes.InsufficientIndexingProgress]: { + summary: "Registrar Actions API has insufficient indexing progress", + value: buildResultInsufficientIndexingProgress( + "The connected ENSIndexer has insufficient omnichain indexing progress to serve this request.", + { + indexingStatus: "omnichain-backfill", + slowestChainIndexingCursor: 1700000000, + earliestIndexingCursor: 1690000000, + progressSufficientFrom: { + indexingStatus: "omnichain-following", + indexingCursor: 1705000000, + }, + }, + ), + }, + }, + }, + }, + }, +}; /** * Get Registrar Actions (all records) @@ -209,12 +261,16 @@ app.get( accurateAsOf, ); - return resultIntoHttpResponse(c, result); + const serializedResult = serializeRegistrarActionsResultOk(result); + + return resultIntoHttpResponse(c, serializedResult); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; logger.error(errorMessage); - const result = buildResultServiceUnavailable("Registrar Actions API Response is unavailable"); + const result = buildResultServiceUnavailable( + `Registrar Actions API Response is unavailable due to following error: ${errorMessage}`, + ); return resultIntoHttpResponse(c, result); } @@ -307,7 +363,9 @@ app.get( const errorMessage = error instanceof Error ? error.message : "Unknown error"; logger.error(errorMessage); - const result = buildResultServiceUnavailable("Registrar Actions API Response is unavailable"); + const result = buildResultServiceUnavailable( + `Registrar Actions API Response is unavailable due to following error: ${errorMessage}`, + ); return resultIntoHttpResponse(c, result); } diff --git a/apps/ensapi/src/lib/handlers/route-responses-description.ts b/apps/ensapi/src/lib/handlers/route-responses-description.ts deleted file mode 100644 index 692ff2fce..000000000 --- a/apps/ensapi/src/lib/handlers/route-responses-description.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { ContentfulStatusCode } from "hono/utils/http-status"; - -import type { AbstractResult } from "@ensnode/ensnode-sdk"; - -import { - resultCodeToHttpStatusCode, - type ServerResultCode, -} from "@/lib/result/result-into-http-response"; - -interface RouteDescription { - description: string; -} - -/** - * Builds a mapping of HTTP status codes to route descriptions - * from a mapping of operation result codes to route descriptions. - * - * @param routes - A record mapping operation result codes to route descriptions - * @returns A record mapping HTTP status codes to route descriptions - */ -export function buildRouteResponsesDescription>( - routes: Record, -): Record { - return Object.entries(routes).reduce( - (acc, entry) => { - const [resultCode, desc] = entry as [TResult["resultCode"], RouteDescription]; - - acc[resultCodeToHttpStatusCode(resultCode)] = desc; - - return acc; - }, - {} as Record, - ); -} diff --git a/apps/ensapi/src/middleware/registrar-actions.middleware.ts b/apps/ensapi/src/middleware/registrar-actions.middleware.ts index dfcaebdc1..42756b4e8 100644 --- a/apps/ensapi/src/middleware/registrar-actions.middleware.ts +++ b/apps/ensapi/src/middleware/registrar-actions.middleware.ts @@ -1,8 +1,13 @@ import config from "@/config"; import { + buildResultInsufficientIndexingProgress, buildResultInternalServerError, buildResultServiceUnavailable, + getChainIndexingConfigTypeId, + getTimestampForHighestOmnichainKnownBlock, + getTimestampForLowestOmnichainStartBlock, + ResultInsufficientIndexingProgress, ResultInternalServerError, ResultServiceUnavailable, registrarActionsPrerequisites, @@ -31,7 +36,10 @@ const logger = makeLogger("registrar-actions.middleware"); * configuration. * 2) ENSApi has not yet successfully cached the Indexing Status in memory from * the connected ENSIndexer. - * 3) The omnichain indexing status of the connected ENSIndexer that is cached + * + * Returns a response from {@link ResultInsufficientIndexingProgress} for any of + * the following cases: + * 1) The omnichain indexing status of the connected ENSIndexer that is cached * in memory is not "completed" or "following". * * @returns Hono middleware that validates the plugin's HTTP API availability. @@ -77,19 +85,37 @@ export const registrarActionsApiMiddleware = factory.createMiddleware( return resultIntoHttpResponse(c, result); } - const { omnichainSnapshot } = c.var.indexingStatus.snapshot; + const { omnichainSnapshot, slowestChainIndexingCursor } = c.var.indexingStatus.snapshot; + + const chains = Array.from(omnichainSnapshot.chains.values()); + const configTypeId = getChainIndexingConfigTypeId(chains); if ( - !registrarActionsPrerequisites.hasIndexingStatusSupport(omnichainSnapshot.omnichainStatus) + !registrarActionsPrerequisites.hasIndexingStatusSupport( + configTypeId, + omnichainSnapshot.omnichainStatus, + ) ) { + const earliestIndexingCursor = getTimestampForLowestOmnichainStartBlock(chains); + const latestIndexingCursor = getTimestampForHighestOmnichainKnownBlock(chains); + + const targetIndexingStatus = + registrarActionsPrerequisites.getSupportedIndexingStatus(configTypeId); + const errorMessage = [ `Registrar Actions API is not available.`, - `The cached omnichain indexing status of the connected ENSIndexer is not supported.`, - `Currently indexing status: "${omnichainSnapshot.omnichainStatus}".`, - `Supported indexing statuses: [${registrarActionsPrerequisites.supportedIndexingStatusIds.map((statusId) => `"${statusId}"`).join(", ")}].`, + `The cached omnichain indexing status of the connected ENSIndexer has insufficient progress.`, ].join(" "); - const result = buildResultServiceUnavailable(errorMessage); + const result = buildResultInsufficientIndexingProgress(errorMessage, { + indexingStatus: omnichainSnapshot.omnichainStatus, + slowestChainIndexingCursor, + earliestIndexingCursor, + progressSufficientFrom: { + indexingStatus: targetIndexingStatus, + indexingCursor: latestIndexingCursor, + }, + }); return resultIntoHttpResponse(c, result); } diff --git a/packages/ensnode-sdk/src/api/amirealtime/zod-schemas.ts b/packages/ensnode-sdk/src/api/amirealtime/zod-schemas.ts new file mode 100644 index 000000000..d02b59950 --- /dev/null +++ b/packages/ensnode-sdk/src/api/amirealtime/zod-schemas.ts @@ -0,0 +1,16 @@ +import z from "zod/v4"; + +import { buildAbstractResultOkSchema } from "../../shared/result/zod-schemas"; +import { makeDurationSchema, makeUnixTimestampSchema } from "../../shared/zod-schemas"; +import type { AmIRealtimeResultOk, AmIRealtimeResultOkData } from "./result"; + +/** + * Schema for {@link AmIRealtimeResultOk}. + */ +export const amIRealtimeResultOkSchema = buildAbstractResultOkSchema( + z.object({ + maxWorstCaseDistance: makeDurationSchema("maxWorstCaseDistance"), + worstCaseDistance: makeDurationSchema("worstCaseDistance"), + slowestChainIndexingCursor: makeUnixTimestampSchema("slowestChainIndexingCursor"), + }), +); diff --git a/packages/ensnode-sdk/src/api/health/result.ts b/packages/ensnode-sdk/src/api/health/result.ts index d31944830..15cf0ac99 100644 --- a/packages/ensnode-sdk/src/api/health/result.ts +++ b/packages/ensnode-sdk/src/api/health/result.ts @@ -18,6 +18,6 @@ export function buildHealthResultOk(data: HealthResultOkData): HealthResultOk { } /** - * The operation result for "Am I Realtime?" API requests. + * The operation result for Health API requests. */ export type HealthServerResult = HealthResultOk; diff --git a/packages/ensnode-sdk/src/api/registrar-actions/prerequisites.ts b/packages/ensnode-sdk/src/api/registrar-actions/prerequisites.ts index afdc12f11..dd8352290 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/prerequisites.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/prerequisites.ts @@ -1,10 +1,16 @@ import { + type ChainIndexingConfigTypeId, + ChainIndexingConfigTypeIds, type ENSIndexerPublicConfig, type OmnichainIndexingStatusId, OmnichainIndexingStatusIds, PluginName, } from "../../ensindexer"; +export type SupportedOmnichainIndexingStatusId = + | typeof OmnichainIndexingStatusIds.Completed + | typeof OmnichainIndexingStatusIds.Following; + export const registrarActionsPrerequisites = Object.freeze({ /** * Required plugins to enable Registrar Actions API routes. @@ -27,6 +33,21 @@ export const registrarActionsPrerequisites = Object.freeze({ PluginName.Registrars, ] as const, + /** + * Gets the omnichain indexing status required to support + * the Registrar Actions API for a given indexing config type. + */ + getSupportedIndexingStatus( + configType: ChainIndexingConfigTypeId, + ): SupportedOmnichainIndexingStatusId { + switch (configType) { + case ChainIndexingConfigTypeIds.Definite: + return OmnichainIndexingStatusIds.Completed; + case ChainIndexingConfigTypeIds.Indefinite: + return OmnichainIndexingStatusIds.Following; + } + }, + /** * Check if provided ENSApiPublicConfig supports the Registrar Actions API. */ @@ -35,23 +56,18 @@ export const registrarActionsPrerequisites = Object.freeze({ config.plugins.includes(plugin), ); }, - /** - * Required Indexing Status IDs - * - * Database indexes are created by the time the omnichain indexing status - * is either `completed` or `following`. - */ - supportedIndexingStatusIds: [ - OmnichainIndexingStatusIds.Completed, - OmnichainIndexingStatusIds.Following, - ], /** - * Check if provided indexing status supports the Registrar Actions API. + * Check if provided indexing status supports the Registrar Actions API, given + * the indexing config type. */ - hasIndexingStatusSupport(omnichainIndexingStatusId: OmnichainIndexingStatusId): boolean { - return registrarActionsPrerequisites.supportedIndexingStatusIds.some( - (supportedIndexingStatusId) => supportedIndexingStatusId === omnichainIndexingStatusId, + hasIndexingStatusSupport( + configType: ChainIndexingConfigTypeId, + omnichainIndexingStatusId: OmnichainIndexingStatusId, + ): boolean { + return ( + registrarActionsPrerequisites.getSupportedIndexingStatus(configType) === + omnichainIndexingStatusId ); }, }); diff --git a/packages/ensnode-sdk/src/api/registrar-actions/result.ts b/packages/ensnode-sdk/src/api/registrar-actions/result.ts index bb8732384..428ee062e 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/result.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/result.ts @@ -1,6 +1,7 @@ import { type AbstractResultOkTimestamped, ResultCodes, + type ResultInsufficientIndexingProgress, type ResultInternalServerError, type ResultInvalidRequest, type ResultServiceUnavailable, @@ -47,7 +48,8 @@ export type SerializedRegistrarActionsResultOk = * * @param data - The data for the successful result * @param minIndexingCursor - The minimum indexing cursor timestamp - * @returns + * @returns The successful result object with data guaranteed to be up to + * the specified indexing cursor. */ export function buildRegistrarActionsResultOk( data: RegistrarActionsResultOkData, @@ -83,6 +85,7 @@ export function serializeRegistrarActionsResultOk( */ export type RegistrarActionsServerResult = | RegistrarActionsResultOk + | ResultInsufficientIndexingProgress | ResultInvalidRequest | ResultInternalServerError | ResultServiceUnavailable; diff --git a/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.test.ts b/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.test.ts index 88d92b8d3..7de23d853 100644 --- a/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.test.ts +++ b/packages/ensnode-sdk/src/api/registrar-actions/zod-schemas.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { ChainIndexingConfigTypeIds } from "../.."; import type { InterpretedName } from "../../ens"; import { registrarActionsPrerequisites } from "../registrar-actions"; import { RegistrarActionsResponseCodes, type RegistrarActionsResponseError } from "./response"; @@ -119,7 +120,7 @@ describe("ENSNode API Schema", () => { responseCode: RegistrarActionsResponseCodes.Error, error: { message: "Registrar Actions API is not available", - details: `The cached omnichain indexing status of the Connected ENSIndexer must be one of the following ${registrarActionsPrerequisites.supportedIndexingStatusIds.map((statusId) => `"${statusId}"`).join(", ")}.`, + details: `The cached omnichain indexing status of the Connected ENSIndexer must be ${registrarActionsPrerequisites.getSupportedIndexingStatus(ChainIndexingConfigTypeIds.Definite)}.`, }, } satisfies SerializedRegistrarActionsResponseError; diff --git a/packages/ensnode-sdk/src/api/shared/pagination/build-page-context.ts b/packages/ensnode-sdk/src/api/shared/pagination/build-page-context.ts index 5ba13f57f..b1ab83f98 100644 --- a/packages/ensnode-sdk/src/api/shared/pagination/build-page-context.ts +++ b/packages/ensnode-sdk/src/api/shared/pagination/build-page-context.ts @@ -26,8 +26,6 @@ export function buildPageContext( totalPages: 1, hasNext: false, hasPrev: false, - startIndex: undefined, - endIndex: undefined, } satisfies ResponsePageContextWithNoRecords; } diff --git a/packages/ensnode-sdk/src/api/shared/pagination/response.ts b/packages/ensnode-sdk/src/api/shared/pagination/response.ts index 5f48dc4e2..99530fb57 100644 --- a/packages/ensnode-sdk/src/api/shared/pagination/response.ts +++ b/packages/ensnode-sdk/src/api/shared/pagination/response.ts @@ -20,16 +20,6 @@ export interface ResponsePageContextWithNoRecords extends Required { diff --git a/packages/ensnode-sdk/src/api/shared/pagination/zod-schemas.ts b/packages/ensnode-sdk/src/api/shared/pagination/zod-schemas.ts index f1b852ac7..c6aae6543 100644 --- a/packages/ensnode-sdk/src/api/shared/pagination/zod-schemas.ts +++ b/packages/ensnode-sdk/src/api/shared/pagination/zod-schemas.ts @@ -36,8 +36,6 @@ export const makeResponsePageContextSchemaWithNoRecords = ( totalPages: z.literal(1), hasNext: z.literal(false), hasPrev: z.literal(false), - startIndex: z.undefined(), - endIndex: z.undefined(), }) .extend(makeRequestPageParamsSchema(valueLabel).shape); @@ -104,6 +102,6 @@ export const makeResponsePageContextSchemaWithRecords = ( */ export const makeResponsePageContextSchema = (valueLabel: string = "ResponsePageContext") => z.union([ - makeResponsePageContextSchemaWithNoRecords(valueLabel), makeResponsePageContextSchemaWithRecords(valueLabel), + makeResponsePageContextSchemaWithNoRecords(valueLabel), ]); diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.test.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.test.ts index ce62a6aee..36c048df4 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.test.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { BlockRef } from "../../shared"; import { createIndexingConfig, + getChainIndexingConfigTypeId, getOmnichainIndexingCursor, getOmnichainIndexingStatus, } from "./helpers"; @@ -197,6 +198,94 @@ describe("ENSIndexer: Indexing Snapshot helpers", () => { } satisfies ChainIndexingConfigIndefinite); }); }); + + describe("getChainIndexingConfigTypeId", () => { + it("returns the correct config type id when all chains have the Definite config", () => { + // arrange + const chainStatuses: ChainIndexingStatusSnapshot[] = [ + { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: latestBlockRef, + }, + } satisfies ChainIndexingStatusSnapshotQueued, + { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: laterBlockRef, + }, + latestIndexedBlock: laterBlockRef, + backfillEndBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill, + ]; + + // act + const configTypeId = getChainIndexingConfigTypeId(chainStatuses); + + // assert + expect(configTypeId).toBe(ChainIndexingConfigTypeIds.Definite); + }); + }); + + it("returns the correct config type id when all chains have the Indefinite config", () => { + // arrange + const chainStatuses: ChainIndexingStatusSnapshot[] = [ + { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earliestBlockRef, + }, + } satisfies ChainIndexingStatusSnapshotQueued, + { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earliestBlockRef, + }, + latestIndexedBlock: laterBlockRef, + backfillEndBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill, + ]; + + // act + const configTypeId = getChainIndexingConfigTypeId(chainStatuses); + + // assert + expect(configTypeId).toBe(ChainIndexingConfigTypeIds.Indefinite); + }); + + it("throws an error when chains have mixed config types", () => { + // arrange + const chainStatuses: ChainIndexingStatusSnapshot[] = [ + { + chainStatus: ChainIndexingStatusIds.Queued, + config: { + configType: ChainIndexingConfigTypeIds.Definite, + startBlock: earliestBlockRef, + endBlock: latestBlockRef, + }, + } satisfies ChainIndexingStatusSnapshotQueued, + { + chainStatus: ChainIndexingStatusIds.Backfill, + config: { + configType: ChainIndexingConfigTypeIds.Indefinite, + startBlock: earliestBlockRef, + }, + latestIndexedBlock: laterBlockRef, + backfillEndBlock: latestBlockRef, + } satisfies ChainIndexingStatusSnapshotBackfill, + ]; + + // act & assert + expect(() => getChainIndexingConfigTypeId(chainStatuses)).toThrowError( + /All ChainIndexingConfigTypeIds must be the same across indexed chains to determine overall ConfigTypeId/i, + ); + }); }); describe("getOmnichainIndexingCursor", () => { diff --git a/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.ts b/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.ts index 1eb58fa9d..0f273e810 100644 --- a/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.ts +++ b/packages/ensnode-sdk/src/ensindexer/indexing-status/helpers.ts @@ -3,6 +3,7 @@ import { type ChainIndexingConfig, type ChainIndexingConfigDefinite, type ChainIndexingConfigIndefinite, + type ChainIndexingConfigTypeId, ChainIndexingConfigTypeIds, ChainIndexingStatusIds, type ChainIndexingStatusSnapshot, @@ -171,6 +172,30 @@ export function createIndexingConfig( } satisfies ChainIndexingConfigIndefinite; } +/** + * Get Chain Indexing Config Type ID from Chain Indexing Status Snapshots. + * @param chains Chain Indexing Status Snapshots + * @returns Chain Indexing Config Type ID + * @throws Error if Chain Indexing Config Type IDs are mixed across chains + */ +export function getChainIndexingConfigTypeId( + chains: ChainIndexingStatusSnapshot[], +): ChainIndexingConfigTypeId { + const chainConfigTypeIds = chains.map((chain) => chain.config.configType); + + if (chainConfigTypeIds.every((typeId) => typeId === ChainIndexingConfigTypeIds.Definite)) { + return ChainIndexingConfigTypeIds.Definite; + } + + if (chainConfigTypeIds.every((typeId) => typeId === ChainIndexingConfigTypeIds.Indefinite)) { + return ChainIndexingConfigTypeIds.Indefinite; + } + + throw new Error( + `Invariant: all ChainIndexingConfigTypeIds must be the same across indexed chains to determine overall ConfigTypeId.`, + ); +} + /** * Check if Chain Indexing Status Snapshots fit the 'unstarted' overall status * snapshot requirements: diff --git a/packages/ensnode-sdk/src/internal.ts b/packages/ensnode-sdk/src/internal.ts index 69dff3369..dc167f8f0 100644 --- a/packages/ensnode-sdk/src/internal.ts +++ b/packages/ensnode-sdk/src/internal.ts @@ -12,6 +12,7 @@ * app/package in the monorepo which requires `@ensnode/ensnode-sdk` dependency. */ +export * from "./api/amirealtime/zod-schemas"; export * from "./api/indexing-status/zod-schemas"; export * from "./api/name-tokens/zod-schemas"; export * from "./api/registrar-actions/zod-schemas"; diff --git a/packages/ensnode-sdk/src/registrars/zod-schemas.ts b/packages/ensnode-sdk/src/registrars/zod-schemas.ts index b67615404..fe5aabd02 100644 --- a/packages/ensnode-sdk/src/registrars/zod-schemas.ts +++ b/packages/ensnode-sdk/src/registrars/zod-schemas.ts @@ -65,14 +65,17 @@ function invariant_registrarActionPricingTotalIsSumOfBaseCostAndPremium( /** * Schema for parsing objects into {@link RegistrarActionPricing}. */ -const makeRegistrarActionPricingSchema = (valueLabel: string = "Registrar Action Pricing") => +const makeRegistrarActionPricingSchema = ( + valueLabel: string = "Registrar Action Pricing", + serializable?: SerializableType, +) => z.union([ // pricing available z .object({ - baseCost: makePriceEthSchema(`${valueLabel} Base Cost`), - premium: makePriceEthSchema(`${valueLabel} Premium`), - total: makePriceEthSchema(`${valueLabel} Total`), + baseCost: makePriceEthSchema(`${valueLabel} Base Cost`, serializable), + premium: makePriceEthSchema(`${valueLabel} Premium`, serializable), + total: makePriceEthSchema(`${valueLabel} Total`, serializable), }) .check(invariant_registrarActionPricingTotalIsSumOfBaseCostAndPremium) .transform((v) => v as RegistrarActionPricingAvailable), @@ -159,7 +162,10 @@ const EventIdsSchema = z .min(1) .transform((v) => v as [RegistrarActionEventId, ...RegistrarActionEventId[]]); -export const makeBaseRegistrarActionSchema = (valueLabel: string = "Base Registrar Action") => +export const makeBaseRegistrarActionSchema = ( + valueLabel: string = "Base Registrar Action", + serializable?: SerializableType, +) => z .object({ id: EventIdSchema, @@ -168,7 +174,7 @@ export const makeBaseRegistrarActionSchema = (valueLabel: string = "Base Registr registrationLifecycle: makeRegistrationLifecycleSchema( `${valueLabel} Registration Lifecycle`, ), - pricing: makeRegistrarActionPricingSchema(`${valueLabel} Pricing`), + pricing: makeRegistrarActionPricingSchema(`${valueLabel} Pricing`, serializable), referral: makeRegistrarActionReferralSchema(`${valueLabel} Referral`), block: makeBlockRefSchema(`${valueLabel} Block`), transactionHash: makeTransactionHashSchema(`${valueLabel} Transaction Hash`), @@ -176,21 +182,30 @@ export const makeBaseRegistrarActionSchema = (valueLabel: string = "Base Registr }) .check(invariant_eventIdsInitialElementIsTheActionId); -export const makeRegistrarActionRegistrationSchema = (valueLabel: string = "Registration ") => - makeBaseRegistrarActionSchema(valueLabel).extend({ +export const makeRegistrarActionRegistrationSchema = ( + valueLabel: string = "Registration ", + serializable?: SerializableType, +) => + makeBaseRegistrarActionSchema(valueLabel, serializable).extend({ type: z.literal(RegistrarActionTypes.Registration), }); -export const makeRegistrarActionRenewalSchema = (valueLabel: string = "Renewal") => - makeBaseRegistrarActionSchema(valueLabel).extend({ +export const makeRegistrarActionRenewalSchema = ( + valueLabel: string = "Renewal", + serializable?: SerializableType, +) => + makeBaseRegistrarActionSchema(valueLabel, serializable).extend({ type: z.literal(RegistrarActionTypes.Renewal), }); /** * Schema for {@link RegistrarAction}. */ -export const makeRegistrarActionSchema = (valueLabel: string = "Registrar Action") => +export const makeRegistrarActionSchema = ( + valueLabel: string = "Registrar Action", + serializable?: SerializableType, +) => z.discriminatedUnion("type", [ - makeRegistrarActionRegistrationSchema(`${valueLabel} Registration`), - makeRegistrarActionRenewalSchema(`${valueLabel} Renewal`), + makeRegistrarActionRegistrationSchema(`${valueLabel} Registration`, serializable), + makeRegistrarActionRenewalSchema(`${valueLabel} Renewal`, serializable), ]); diff --git a/packages/ensnode-sdk/src/shared/zod-schemas.ts b/packages/ensnode-sdk/src/shared/zod-schemas.ts index ab07a4018..5a8ca4b1e 100644 --- a/packages/ensnode-sdk/src/shared/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/zod-schemas.ts @@ -250,21 +250,43 @@ export const makeENSNamespaceIdSchema = (valueLabel: string = "ENSNamespaceId") }, }); -const makePriceAmountSchema = (valueLabel: string = "Amount") => - z.coerce - .bigint({ - error: `${valueLabel} must represent a bigint.`, - }) - .nonnegative({ - error: `${valueLabel} must not be negative.`, - }); +const priceAmountSchemaSerializable = z.string(); +const makePriceAmountSchemaNative = (valueLabel: string = "Price Amount") => + z.preprocess( + (v) => (typeof v === "string" ? BigInt(v) : v), + z + .bigint({ + error: `${valueLabel} must represent a bigint.`, + }) + .nonnegative({ + error: `${valueLabel} must not be negative.`, + }), + ); -export const makePriceCurrencySchema = ( +export function makePriceAmountSchema( + _valueLabel: string, + serializable: SerializableType, +): SerializableType extends true + ? typeof priceAmountSchemaSerializable + : ReturnType; +export function makePriceAmountSchema( + _valueLabel: string = " Price Amount Schema", + serializable: true | false = false, +): typeof priceAmountSchemaSerializable | ReturnType { + if (serializable) { + return priceAmountSchemaSerializable; + } else { + return makePriceAmountSchemaNative(_valueLabel); + } +} + +export const makePriceCurrencySchema = ( currency: CurrencyId, valueLabel: string = "Price Currency", + serializable: SerializableType, ) => z.strictObject({ - amount: makePriceAmountSchema(`${valueLabel} amount`), + amount: makePriceAmountSchema(`${valueLabel} amount`, serializable), currency: z.literal(currency, { error: `${valueLabel} currency must be set to '${currency}'.`, @@ -274,13 +296,16 @@ export const makePriceCurrencySchema = ( /** * Schema for {@link Price} type. */ -export const makePriceSchema = (valueLabel: string = "Price") => +export const makePriceSchema = ( + valueLabel: string = "Price", + serializable?: SerializableType, +) => z.discriminatedUnion( "currency", [ - makePriceCurrencySchema(CurrencyIds.ETH, valueLabel), - makePriceCurrencySchema(CurrencyIds.USDC, valueLabel), - makePriceCurrencySchema(CurrencyIds.DAI, valueLabel), + makePriceCurrencySchema(CurrencyIds.ETH, valueLabel, serializable ?? false), + makePriceCurrencySchema(CurrencyIds.USDC, valueLabel, serializable ?? false), + makePriceCurrencySchema(CurrencyIds.DAI, valueLabel, serializable ?? false), ], { error: `${valueLabel} currency must be one of ${Object.values(CurrencyIds).join(", ")}` }, ); @@ -288,8 +313,13 @@ export const makePriceSchema = (valueLabel: string = "Price") => /** * Schema for {@link PriceEth} type. */ -export const makePriceEthSchema = (valueLabel: string = "Price ETH") => - makePriceCurrencySchema(CurrencyIds.ETH, valueLabel).transform((v) => v as PriceEth); +export const makePriceEthSchema = ( + valueLabel: string = "Price ETH", + serializable?: SerializableType, +) => + makePriceCurrencySchema(CurrencyIds.ETH, valueLabel, serializable ?? false).transform( + (v) => v as PriceEth, + ); /** * Schema for {@link AccountId} type. From fe3ffcc785a6416b1f8b1f649785eb6eb3e9b21c Mon Sep 17 00:00:00 2001 From: Tomasz Kopacki Date: Wed, 21 Jan 2026 18:00:55 +0100 Subject: [PATCH 14/23] feat(ensadmin): update `StatefulFetchRegistrarActionsNotReady` type This matches refined prerequisites object for Registrar Actions API. --- .../src/app/mock/registrar-actions/mocks.ts | 3 ++- .../display-registrar-actions-panel.tsx | 17 +++++------------ .../src/components/registrar-actions/types.ts | 6 ++++-- .../use-stateful-fetch-registrar-actions.ts | 13 +++++++++---- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/apps/ensadmin/src/app/mock/registrar-actions/mocks.ts b/apps/ensadmin/src/app/mock/registrar-actions/mocks.ts index 28eb8af5d..6abb834b2 100644 --- a/apps/ensadmin/src/app/mock/registrar-actions/mocks.ts +++ b/apps/ensadmin/src/app/mock/registrar-actions/mocks.ts @@ -2,6 +2,7 @@ import { Duration, InterpretedName, NamedRegistrarAction, + OmnichainIndexingStatusIds, registrarActionsPrerequisites, } from "@ensnode/ensnode-sdk"; @@ -248,7 +249,7 @@ export const variants: Map StatefulFetchStatusIds.NotReady, { fetchStatus: StatefulFetchStatusIds.NotReady, - supportedIndexingStatusIds: registrarActionsPrerequisites.supportedIndexingStatusIds, + supportedIndexingStatusId: OmnichainIndexingStatusIds.Following, } satisfies StatefulFetchRegistrarActionsNotReady, ], [ diff --git a/apps/ensadmin/src/components/registrar-actions/display-registrar-actions-panel.tsx b/apps/ensadmin/src/components/registrar-actions/display-registrar-actions-panel.tsx index 7a176e884..af5587ca8 100644 --- a/apps/ensadmin/src/components/registrar-actions/display-registrar-actions-panel.tsx +++ b/apps/ensadmin/src/components/registrar-actions/display-registrar-actions-panel.tsx @@ -153,19 +153,12 @@ export function DisplayRegistrarActionsPanel({

The Registrar Actions API on the connected ENSNode instance is not available yet.

- The Registrar Actions API will be available once the omnichain indexing status reaches - one of the following: + The Registrar Actions API will be available once the omnichain indexing status becomes{" "} + + {formatOmnichainIndexingStatus(registrarActions.supportedIndexingStatusId)} + + .

- -
    - {registrarActions.supportedIndexingStatusIds.map((supportedStatusId) => ( -
  • - - {formatOmnichainIndexingStatus(supportedStatusId)} - {" "} -
  • - ))} -