diff --git a/packages/ensnode-sdk/src/shared/index.ts b/packages/ensnode-sdk/src/shared/index.ts index e628311f5..99b64f257 100644 --- a/packages/ensnode-sdk/src/shared/index.ts +++ b/packages/ensnode-sdk/src/shared/index.ts @@ -10,6 +10,7 @@ export * from "./interpretation"; export * from "./labelhash"; export * from "./null-bytes"; export * from "./numbers"; +export * from "./result"; export * from "./root-registry"; export * from "./serialize"; export * from "./serialized-types"; diff --git a/packages/ensnode-sdk/src/shared/result/examples/dx-client.ts b/packages/ensnode-sdk/src/shared/result/examples/dx-client.ts new file mode 100644 index 000000000..03fa1f445 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/dx-client.ts @@ -0,0 +1,31 @@ +/** + * Example of a simple client-side application client that calls an operation + * returning Result data model. + * + * In a real-world scenario, this could be part of a frontend application + * calling a client to send a request to a backend service and handle + * the response. + * + * In this example, we show how to handle both successful and error results + * returned by the operation. This includes a retry suggestion for + * certain error cases. + */ +import type { Address } from "viem"; + +import { ResultCodes } from "../result-code"; +import { callExampleOp } from "./op-client"; + +export const myExampleDXClient = (address: Address): void => { + const result = callExampleOp(address); + + if (result.resultCode === ResultCodes.Ok) { + // NOTE: Here the type system knows that `result` is of type `ResultExampleOpOk` + console.log(result.data.name); + } else { + // NOTE: Here the type system knows that `result` has fields for `errorMessage` and `suggestRetry` + console.error(`Error: (${result.resultCode}) - ${result.errorMessage}`); + if (result.suggestRetry) { + console.log("Try again?"); + } + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/examples/dx-hook.ts b/packages/ensnode-sdk/src/shared/result/examples/dx-hook.ts new file mode 100644 index 000000000..c21482d65 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/dx-hook.ts @@ -0,0 +1,33 @@ +/** + * Example of a simple client-side DX hook that consumes an operation + * returning Result data model. + * + * In a real-world scenario, this could be part of a React component + * calling a hook to manage async data fetching. + * + * In this example, we show how to handle both successful and error results + * returned by the operation. This includes a retry suggestion for + * certain error cases. + */ +import type { Address } from "viem"; + +import { ResultCodes } from "../result-code"; +import { useExampleOp } from "./op-hook"; + +export const myExampleDXHook = (address: Address): void => { + const result = useExampleOp(address); + + if (result.resultCode === ResultCodes.Loading) { + // NOTE: Here the type system knows that `result` is of type `ResultExampleOpLoading` + console.log("Loading..."); + } else if (result.resultCode === ResultCodes.Ok) { + // NOTE: Here the type system knows that `result` is of type `ResultExampleOpOk` + console.log(result.data.name); + } else { + // NOTE: Here the type system knows that `result` has fields for `errorMessage` and `suggestRetry` + console.error(`Error: (${result.resultCode}) - ${result.errorMessage}`); + if (result.suggestRetry) { + console.log("Try again?"); + } + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/examples/op-client.ts b/packages/ensnode-sdk/src/shared/result/examples/op-client.ts new file mode 100644 index 000000000..4d0d4be5b --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/op-client.ts @@ -0,0 +1,56 @@ +/** + * Example of a simple client-side operation that calls a server operation + * and returns Result data model. + * + * Note: In a real-world scenario, this would involve making an HTTP request + * to a server endpoint. Here, for simplicity, we directly call the server + * operation function. + * + * We also simulate client-side errors like connection errors and timeouts. + * + * If the server returns a result code that is not recognized by this client + * version, the client handles it by returning a special unrecognized operation + * result. + */ +import type { Address } from "viem"; + +import { + buildResultClientUnrecognizedOperationResult, + buildResultConnectionError, + buildResultRequestTimeout, + isRecognizedResultCodeForOperation, + type ResultClientError, +} from "../result-common"; +import { + EXAMPLE_OP_RECOGNIZED_SERVER_RESULT_CODES, + type ExampleOpServerResult, + exampleOp, +} from "./op-server"; + +export type ExampleOpClientResult = ExampleOpServerResult | ResultClientError; + +export const callExampleOp = (address: Address): ExampleOpClientResult => { + try { + const result = exampleOp(address); + + // ensure server result code is recognized by this client version + if ( + !isRecognizedResultCodeForOperation( + result.resultCode, + EXAMPLE_OP_RECOGNIZED_SERVER_RESULT_CODES, + ) + ) { + return buildResultClientUnrecognizedOperationResult(result); + } + + // return server result + return result; + } catch (error) { + // handle client-side errors + if (error === "connection-error") { + return buildResultConnectionError(); + } else { + return buildResultRequestTimeout(); + } + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/examples/op-hook.ts b/packages/ensnode-sdk/src/shared/result/examples/op-hook.ts new file mode 100644 index 000000000..e84b58b86 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/op-hook.ts @@ -0,0 +1,34 @@ +/** + * Example of a simple client-side operation hook that returns + * Result data model with Loading state. + */ +import type { Address } from "viem"; + +import type { AbstractResultLoading } from "../result-base"; +import { ResultCodes } from "../result-code"; +import { callExampleOp, type ExampleOpClientResult } from "./op-client"; + +export interface ExampleOpLoadingData { + address: Address; +} + +export interface ResultExampleOpLoading extends AbstractResultLoading {} + +export const buildResultExampleOpLoading = (address: Address): ResultExampleOpLoading => { + return { + resultCode: ResultCodes.Loading, + data: { + address, + }, + }; +}; + +export type ExampleOpHookResult = ExampleOpClientResult | ResultExampleOpLoading; + +export const useExampleOp = (address: Address): ExampleOpHookResult => { + if (Math.random() < 0.5) { + return buildResultExampleOpLoading(address); + } else { + return callExampleOp(address); + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/examples/op-server.ts b/packages/ensnode-sdk/src/shared/result/examples/op-server.ts new file mode 100644 index 000000000..3f58ceb0f --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/op-server.ts @@ -0,0 +1,70 @@ +/** + * Example of a simple server-side operation that returns Result data model. + * + * In a real-world scenario, this could be part of a backend service + * handling requests and returning structured responses. + * + * In this example, we show how to return both successful and error results + * based on input conditions. + */ +import type { Address } from "viem"; +import { zeroAddress } from "viem"; + +import type { AbstractResultOk } from "../result-base"; +import { type AssertResultCodeExact, type ExpectTrue, ResultCodes } from "../result-code"; +import { + buildResultInternalServerError, + buildResultInvalidRequest, + type ResultInternalServerError, + type ResultInvalidRequest, +} from "../result-common"; + +export interface ResultExampleOpOkData { + name: string; +} + +export interface ResultExampleOpOk extends AbstractResultOk {} + +export const buildResultExampleOpOk = (name: string): ResultExampleOpOk => { + return { + resultCode: ResultCodes.Ok, + data: { + name, + }, + }; +}; + +// NOTE: Here we define a union of all possible results returned by the server for this operation. +// We specifically call these "Server Results" because later we need to add all the possible client error results to get +// the full set of all results a client can receive from this operation. +export type ExampleOpServerResult = + | ResultExampleOpOk + | ResultInternalServerError + | ResultInvalidRequest; + +export type ExampleOpServerResultCode = ExampleOpServerResult["resultCode"]; + +export const EXAMPLE_OP_RECOGNIZED_SERVER_RESULT_CODES = [ + ResultCodes.Ok, + ResultCodes.InternalServerError, + ResultCodes.InvalidRequest, +] as const satisfies readonly ExampleOpServerResultCode[]; + +// Intentionally unused: compile-time assertion that the recognized result codes +// exactly match the union of ExampleOpServerResult["resultCode"]. +type _AssertExampleOpServerResultCodesMatch = ExpectTrue< + AssertResultCodeExact +>; + +export const exampleOp = (address: Address): ExampleOpServerResult => { + if (address === zeroAddress) { + return buildResultInvalidRequest("Address must not be the zero address"); + } + if (Math.random() < 0.5) { + return buildResultExampleOpOk("example.eth"); + } else { + return buildResultInternalServerError( + "Invariant violation: random number is not less than 0.5", + ); + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/examples/server-router.ts b/packages/ensnode-sdk/src/shared/result/examples/server-router.ts new file mode 100644 index 000000000..87a0e880e --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/examples/server-router.ts @@ -0,0 +1,34 @@ +/** + * Example of a simple server-side router handling requests and + * returning Result data model. + * + * In a real-world scenario, this could be part of a backend service + * using a framework like Hono to route requests and return structured + * responses. + * + * In this example, we show how different results are returned + * based on the request path, including delegating to an operation + * that also returns Result data model. + */ +import type { Address } from "viem"; + +import type { AbstractResult } from "../result-base"; +import type { ResultCode } from "../result-code"; +import { buildResultInternalServerError, buildResultNotFound } from "../result-common"; +import { exampleOp } from "./op-server"; + +const _routeRequest = (path: string): AbstractResult => { + // imagine Hono router logic here + try { + if (path === "/example") { + return exampleOp("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" as Address); + } else { + // guarantee in all cases we return our Result data model + return buildResultNotFound(`Path not found: ${path}`); + } + } catch (error) { + // guarantee in all cases we return our Result data model + const errorMessage = error instanceof Error ? error.message : undefined; + return buildResultInternalServerError(errorMessage); + } +}; diff --git a/packages/ensnode-sdk/src/shared/result/index.ts b/packages/ensnode-sdk/src/shared/result/index.ts new file mode 100644 index 000000000..70fbbd582 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/index.ts @@ -0,0 +1,3 @@ +export * from "./result-base"; +export * from "./result-code"; +export * from "./result-common"; diff --git a/packages/ensnode-sdk/src/shared/result/result-base.ts b/packages/ensnode-sdk/src/shared/result/result-base.ts new file mode 100644 index 000000000..7f0477ef8 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/result-base.ts @@ -0,0 +1,69 @@ +import type { + ResultCode, + ResultCodeClientError, + ResultCodeServerError, + ResultCodes, +} from "./result-code"; + +/************************************************************ + * Abstract results + * + * These are base interfaces that should be extended to + * create concrete result types. + ************************************************************/ + +/** + * Abstract representation of any result. + */ +export interface AbstractResult { + /** + * The classification of the result. + */ + resultCode: TResultCode; +} + +/** + * Abstract representation of a successful result. + */ +export interface AbstractResultOk extends AbstractResult { + /** + * The data of the result. + */ + data: TDataType; +} + +/** + * Abstract representation of an error result. + */ +export interface AbstractResultError< + TResultCode extends ResultCodeServerError | ResultCodeClientError, + TDataType = undefined, +> extends AbstractResult { + /** + * A description of the error. + */ + errorMessage: string; + + /** + * Identifies if it may be relevant to retry the operation. + * + * If `false`, retrying the operation is unlikely to be helpful. + */ + suggestRetry: boolean; + + /** + * Optional data associated with the error. + */ + data?: TDataType; +} + +/** + * Abstract representation of a loading result. + */ +export interface AbstractResultLoading + extends AbstractResult { + /** + * Optional data associated with the loading operation. + */ + data?: TDataType; +} diff --git a/packages/ensnode-sdk/src/shared/result/result-code.ts b/packages/ensnode-sdk/src/shared/result/result-code.ts new file mode 100644 index 000000000..52e376ea9 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/result-code.ts @@ -0,0 +1,143 @@ +/** + * Classifies a result returned by an operation. + */ +export const ResultCodes = { + /** + * Indefinite result. + */ + Loading: "loading", + + /** + * Successful result. + */ + Ok: "ok", + + /** + * Server error: the operation failed due to an unexpected error internally within the server. + */ + InternalServerError: "internal-server-error", + + /** + * Server error: the requested resource was not found. + */ + NotFound: "not-found", + + /** + * Server error: the request was invalid. + */ + InvalidRequest: "invalid-request", + + /** + * Client error: the connection to the server failed. + */ + ConnectionError: "connection-error", + + /** + * Client error: the request timed out. + */ + RequestTimeout: "request-timeout", + + /** + * Client error: received an unrecognized result from the server for an operation. + */ + ClientUnrecognizedOperationResult: "client-unrecognized-operation-result", +} as const; + +/** + * List of ResultCodes that represent server error results. + */ +export const RESULT_CODE_SERVER_ERROR_CODES = [ + ResultCodes.InternalServerError, + ResultCodes.NotFound, + ResultCodes.InvalidRequest, +] as const; + +/** + * List of ResultCodes that represent client error results. + */ +export const RESULT_CODE_CLIENT_ERROR_CODES = [ + ResultCodes.ConnectionError, + ResultCodes.RequestTimeout, + ResultCodes.ClientUnrecognizedOperationResult, +] as const; + +/** + * 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, + ...RESULT_CODE_SERVER_ERROR_CODES, +] as const; + +/** + * List of all ResultCodes. + */ +export const RESULT_CODE_ALL_CODES = [ + ResultCodes.Loading, + ResultCodes.Ok, + ...RESULT_CODE_ALL_ERROR_CODES, +] as const; + +/** + * Classifies a result returned by an operation. + */ +export type ResultCode = (typeof ResultCodes)[keyof typeof ResultCodes]; + +/** + * ResultCode for a result that is not yet determined. + */ +export type ResultCodeIndefinite = typeof ResultCodes.Loading; + +/** + * ResultCode for a result that has been determined. + */ +export type ResultCodeDefinite = Exclude; + +/** + * ResultCode for an error result that may be determined by the server. + */ +export type ResultCodeServerError = (typeof RESULT_CODE_SERVER_ERROR_CODES)[number]; + +/** + * ResultCode for an error result that may be determined by the client. + */ +export type ResultCodeClientError = (typeof RESULT_CODE_CLIENT_ERROR_CODES)[number]; + +/************************************************************ + * Compile-time helpers to ensure invariants expected of + * definitions above are maintained and don't become + * out of sync. + ************************************************************/ + +export type ExpectTrue = T; + +export type ResultCodesFromList = List[number]; + +export type AssertResultCodeSuperset< + Union extends ResultCode, + List extends readonly ResultCode[], +> = Union extends ResultCodesFromList ? true : false; + +export type AssertResultCodeSubset< + Union extends ResultCode, + List extends readonly ResultCode[], +> = ResultCodesFromList extends Union ? true : false; + +export type AssertResultCodeExact< + Union extends ResultCode, + List extends readonly ResultCode[], +> = AssertResultCodeSuperset extends true + ? AssertResultCodeSubset extends true + ? true + : false + : false; + +/** + * Intentionally unused type alias used only for compile-time verification that + * the `ResultCode` union exactly matches the entries in `RESULT_CODE_ALL_CODES`. + * If this type ever fails to compile, it indicates that one of the above + * invariants has been broken and the result code definitions are out of sync. + */ +type _CompileTimeCheck_ResultCodeMatchesUnion = ExpectTrue< + AssertResultCodeExact +>; diff --git a/packages/ensnode-sdk/src/shared/result/result-common.test.ts b/packages/ensnode-sdk/src/shared/result/result-common.test.ts new file mode 100644 index 000000000..d5069326c --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/result-common.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from "vitest"; + +import { ResultCodes } from "./result-code"; +import { + buildResultClientUnrecognizedOperationResult, + buildResultConnectionError, + buildResultInternalServerError, + buildResultInvalidRequest, + buildResultNotFound, + buildResultRequestTimeout, + isRecognizedResultCodeForOperation, +} from "./result-common"; + +describe("Result Error Builders", () => { + describe("buildResultInternalServerError", () => { + it("should build an internal server error with custom message", () => { + const result = buildResultInternalServerError("Database connection failed"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InternalServerError, + errorMessage: "Database connection failed", + suggestRetry: true, + }); + }); + + it("should build an internal server error with default message when not provided", () => { + const result = buildResultInternalServerError(); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InternalServerError, + errorMessage: "An unknown internal server error occurred.", + suggestRetry: true, + }); + }); + + it("should respect suggestRetry parameter", () => { + const result = buildResultInternalServerError("Error", false); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InternalServerError, + errorMessage: "Error", + suggestRetry: false, + }); + }); + }); + + describe("buildResultNotFound", () => { + it("should build a not found error with custom message", () => { + const result = buildResultNotFound("User not found"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.NotFound, + errorMessage: "User not found", + suggestRetry: false, + }); + }); + + it("should build a not found error with default message when not provided", () => { + const result = buildResultNotFound(); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.NotFound, + errorMessage: "Requested resource not found.", + suggestRetry: false, + }); + }); + + it("should allow retry suggestion for not found errors", () => { + const result = buildResultNotFound("Not found", true); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.NotFound, + errorMessage: "Not found", + suggestRetry: true, + }); + }); + }); + + describe("buildResultInvalidRequest", () => { + it("should build an invalid request error with custom message", () => { + const result = buildResultInvalidRequest("Missing required field: email"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InvalidRequest, + errorMessage: "Missing required field: email", + suggestRetry: false, + }); + }); + + it("should build an invalid request error with default message when not provided", () => { + const result = buildResultInvalidRequest(); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InvalidRequest, + errorMessage: "Invalid request.", + suggestRetry: false, + }); + }); + + it("should allow retry suggestion for invalid request errors", () => { + const result = buildResultInvalidRequest("Bad input", true); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.InvalidRequest, + errorMessage: "Bad input", + suggestRetry: true, + }); + }); + }); + + describe("buildResultConnectionError", () => { + it("should build a connection error with custom message", () => { + const result = buildResultConnectionError("Failed to connect to server"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ConnectionError, + errorMessage: "Failed to connect to server", + suggestRetry: true, + }); + }); + + it("should build a connection error with default message when not provided", () => { + const result = buildResultConnectionError(); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ConnectionError, + errorMessage: "Connection error.", + suggestRetry: true, + }); + }); + + it("should respect suggestRetry parameter", () => { + const result = buildResultConnectionError("Connection failed", false); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ConnectionError, + errorMessage: "Connection failed", + suggestRetry: false, + }); + }); + }); + + describe("buildResultRequestTimeout", () => { + it("should build a request timeout error with custom message", () => { + const result = buildResultRequestTimeout("Request exceeded 30 second limit"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.RequestTimeout, + errorMessage: "Request exceeded 30 second limit", + suggestRetry: true, + }); + }); + + it("should build a request timeout error with default message when not provided", () => { + const result = buildResultRequestTimeout(); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.RequestTimeout, + errorMessage: "Request timed out.", + suggestRetry: true, + }); + }); + + it("should respect suggestRetry parameter", () => { + const result = buildResultRequestTimeout("Timeout", false); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.RequestTimeout, + errorMessage: "Timeout", + suggestRetry: false, + }); + }); + }); + + describe("buildResultClientUnrecognizedOperationResult", () => { + it("should build unrecognized result with default values for unknown input", () => { + const result = buildResultClientUnrecognizedOperationResult("unknown"); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "An unrecognized result for the operation occurred.", + suggestRetry: true, + }); + }); + + it("should extract errorMessage from object", () => { + const unrecognizedResult = { errorMessage: "Custom error message" }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "Custom error message", + suggestRetry: true, + }); + }); + + it("should extract suggestRetry from object", () => { + const unrecognizedResult = { suggestRetry: false }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "An unrecognized result for the operation occurred.", + suggestRetry: false, + }); + }); + + it("should extract both errorMessage and suggestRetry from object", () => { + const unrecognizedResult = { + errorMessage: "Custom error", + suggestRetry: false, + }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "Custom error", + suggestRetry: false, + }); + }); + + it("should ignore non-string errorMessage", () => { + const unrecognizedResult = { errorMessage: 123 }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "An unrecognized result for the operation occurred.", + suggestRetry: true, + }); + }); + + it("should ignore non-boolean suggestRetry", () => { + const unrecognizedResult = { suggestRetry: "true" }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "An unrecognized result for the operation occurred.", + suggestRetry: true, + }); + }); + + it("should handle null input", () => { + const result = buildResultClientUnrecognizedOperationResult(null); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "An unrecognized result for the operation occurred.", + suggestRetry: true, + }); + }); + + it("should handle object with extra properties", () => { + const unrecognizedResult = { + errorMessage: "Error occurred", + suggestRetry: false, + extraField: "ignored", + nested: { data: "also ignored" }, + }; + const result = buildResultClientUnrecognizedOperationResult(unrecognizedResult); + + expect(result).toStrictEqual({ + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage: "Error occurred", + suggestRetry: false, + }); + }); + }); + + describe("isRecognizedResultCodeForOperation", () => { + const recognizedCodes = [ + ResultCodes.InternalServerError, + ResultCodes.NotFound, + ResultCodes.InvalidRequest, + ] as const; + + it("should return true for recognized result code", () => { + expect( + isRecognizedResultCodeForOperation(ResultCodes.InternalServerError, recognizedCodes), + ).toBe(true); + }); + + it("should return false for unrecognized result code", () => { + expect(isRecognizedResultCodeForOperation(ResultCodes.ConnectionError, recognizedCodes)).toBe( + false, + ); + }); + + it("should return false for unknown string result codes", () => { + expect(isRecognizedResultCodeForOperation("UnknownCode", recognizedCodes)).toBe(false); + }); + + it("should handle empty recognized codes array", () => { + expect(isRecognizedResultCodeForOperation(ResultCodes.InternalServerError, [])).toBe(false); + }); + + it("should handle all result codes in recognized list", () => { + const allCodes = [ + ResultCodes.InternalServerError, + ResultCodes.NotFound, + ResultCodes.InvalidRequest, + ResultCodes.ConnectionError, + ResultCodes.RequestTimeout, + ResultCodes.ClientUnrecognizedOperationResult, + ] as const; + + allCodes.forEach((code) => { + expect(isRecognizedResultCodeForOperation(code, allCodes)).toBe(true); + }); + }); + }); +}); diff --git a/packages/ensnode-sdk/src/shared/result/result-common.ts b/packages/ensnode-sdk/src/shared/result/result-common.ts new file mode 100644 index 000000000..d13ab55a9 --- /dev/null +++ b/packages/ensnode-sdk/src/shared/result/result-common.ts @@ -0,0 +1,176 @@ +/************************************************************ + * Internal Server Error + ************************************************************/ + +import type { AbstractResultError } from "./result-base"; +import { type ResultCode, ResultCodes } from "./result-code"; + +export interface ResultInternalServerError + extends AbstractResultError {} + +/** + * Builds a result object representing an internal server error. + */ +export const buildResultInternalServerError = ( + errorMessage?: string, + suggestRetry: boolean = true, +): ResultInternalServerError => { + return { + resultCode: ResultCodes.InternalServerError, + errorMessage: errorMessage ?? "An unknown internal server error occurred.", + suggestRetry, + }; +}; + +/************************************************************ + * Not Found + ************************************************************/ + +export interface ResultNotFound extends AbstractResultError {} + +/** + * Builds a result object representing a not found error. + */ +export const buildResultNotFound = ( + errorMessage?: string, + suggestRetry: boolean = false, +): ResultNotFound => { + return { + resultCode: ResultCodes.NotFound, + errorMessage: errorMessage ?? "Requested resource not found.", + suggestRetry, + }; +}; + +/************************************************************ + * Invalid Request + ************************************************************/ + +export interface ResultInvalidRequest + extends AbstractResultError {} + +/** + * Builds a result object representing an invalid request error. + */ +export const buildResultInvalidRequest = ( + errorMessage?: string, + suggestRetry: boolean = false, +): ResultInvalidRequest => { + return { + resultCode: ResultCodes.InvalidRequest, + errorMessage: errorMessage ?? "Invalid request.", + suggestRetry, + }; +}; + +/************************************************************ + * Connection Error + ************************************************************/ + +export interface ResultConnectionError + extends AbstractResultError {} + +/** + * Builds a result object representing a connection error. + */ +export const buildResultConnectionError = ( + errorMessage?: string, + suggestRetry: boolean = true, +): ResultConnectionError => { + return { + resultCode: ResultCodes.ConnectionError, + errorMessage: errorMessage ?? "Connection error.", + suggestRetry, + }; +}; + +/************************************************************ + * Request Timeout + ************************************************************/ + +export interface ResultRequestTimeout + extends AbstractResultError {} + +/** + * Builds a result object representing a request timeout error. + */ +export const buildResultRequestTimeout = ( + errorMessage?: string, + suggestRetry: boolean = true, +): ResultRequestTimeout => { + return { + resultCode: ResultCodes.RequestTimeout, + errorMessage: errorMessage ?? "Request timed out.", + suggestRetry, + }; +}; + +/************************************************************ + * Client-Unrecognized Operation Result + ************************************************************/ + +/** + * Represents an operation result with a result code that is not recognized + * by this client version. + * + * Relevant for cases where a client is running version X while the server + * is running version X+N and the server returns a result code that is not + * recognized by a client for a specific operation because the result code + * exists in version X+N for the operation on the server but not in the + * version X for the operation on the client and therefore needs + * transformation into a fallback result code for the client that is safe + * for recognition by clients that are running version X. + */ +export interface ResultClientUnrecognizedOperationResult + extends AbstractResultError {} + +/** + * Builds a result object representing an unrecognized operation result. + */ +export const buildResultClientUnrecognizedOperationResult = ( + unrecognizedResult: unknown, +): ResultClientUnrecognizedOperationResult => { + let errorMessage = "An unrecognized result for the operation occurred."; + let suggestRetry = true; + + if (typeof unrecognizedResult === "object" && unrecognizedResult !== null) { + if ( + "errorMessage" in unrecognizedResult && + typeof unrecognizedResult.errorMessage === "string" + ) { + errorMessage = unrecognizedResult.errorMessage; + } + if ( + "suggestRetry" in unrecognizedResult && + typeof unrecognizedResult.suggestRetry === "boolean" + ) { + suggestRetry = unrecognizedResult.suggestRetry; + } + } + + return { + resultCode: ResultCodes.ClientUnrecognizedOperationResult, + errorMessage, + suggestRetry, + }; +}; + +/** + * Checks if a result code is recognized for a specific operation. + */ +export const isRecognizedResultCodeForOperation = ( + resultCode: ResultCode | string, + recognizedResultCodesForOperation: readonly ResultCode[], +): boolean => { + // Checks if resultCode is one of the recognizedResultCodes for an operation + return recognizedResultCodesForOperation.includes(resultCode as ResultCode); +}; + +/************************************************************ + * All common client errors + ************************************************************/ + +export type ResultClientError = + | ResultConnectionError + | ResultRequestTimeout + | ResultClientUnrecognizedOperationResult;