Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/ensapi/src/config/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import pRetry from "p-retry";
import { parse as parseConnectionString } from "pg-connection-string";
import { prettifyError, ZodError, z } from "zod/v4";

import { type ENSApiPublicConfig, serializeENSIndexerPublicConfig } from "@ensnode/ensnode-sdk";
import type { EnsApiPublicConfig } from "@ensnode/ensnode-sdk";
import {
buildRpcConfigsFromEnv,
canFallbackToTheGraph,
Expand Down Expand Up @@ -103,7 +103,7 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis
databaseUrl: env.DATABASE_URL,
ensIndexerUrl: env.ENSINDEXER_URL,
theGraphApiKey: env.THEGRAPH_API_KEY,
ensIndexerPublicConfig: serializeENSIndexerPublicConfig(ensIndexerPublicConfig),
ensIndexerPublicConfig,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EnsApiConfigSchema is now purely business-layer Zod schema, so we don't need to serialize data to be able to use it.

namespace: ensIndexerPublicConfig.namespace,
databaseSchemaName: ensIndexerPublicConfig.databaseSchemaName,
rpcConfigs,
Expand All @@ -128,7 +128,7 @@ export async function buildConfigFromEnvironment(env: EnsApiEnvironment): Promis
* @param config - The validated EnsApiConfig object
* @returns A complete ENSApiPublicConfig object
*/
export function buildEnsApiPublicConfig(config: EnsApiConfig): ENSApiPublicConfig {
export function buildEnsApiPublicConfig(config: EnsApiConfig): EnsApiPublicConfig {
return {
version: packageJson.version,
theGraphFallback: canFallbackToTheGraph({
Expand Down
5 changes: 3 additions & 2 deletions packages/ensnode-sdk/src/ensapi/api/config/deserialize.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Unvalidated } from "../../../shared/types";
import { deserializeEnsApiPublicConfig } from "../../config/deserialize";
import type { EnsApiConfigResponse } from "./response";
import type { SerializedEnsApiConfigResponse } from "./serialized-response";
Expand All @@ -6,9 +7,9 @@ import type { SerializedEnsApiConfigResponse } from "./serialized-response";
* Deserialize a {@link EnsApiConfigResponse} object.
*/
export function deserializeEnsApiConfigResponse(
serializedResponse: SerializedEnsApiConfigResponse,
maybeResponse: Unvalidated<SerializedEnsApiConfigResponse>,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
maybeResponse: Unvalidated<SerializedEnsApiConfigResponse>,
unvalidatedResponse: Unvalidated<SerializedEnsApiConfigResponse>,

Goal: "maybe response" sounds funny. It sounds like it's not sure if this was the response or not. But as I understand, we know it was the response, we just don't know if its a valid one or not yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it might sound funny, but also we convey information that the input is not guaranteed to be a response. Before, we'd have maybeResponse: unknown. But we also discussed how it gives zero hints about what the input value can be. I suggest we stick to "maybe" here, or we need to think of a better way of communicating how the input can be anything, but should be in shape of SerializedEnsApiConfigResponse type.

): EnsApiConfigResponse {
return deserializeEnsApiPublicConfig(serializedResponse);
return deserializeEnsApiPublicConfig(maybeResponse);
}

/**
Expand Down
47 changes: 35 additions & 12 deletions packages/ensnode-sdk/src/ensapi/config/deserialize.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,49 @@
import { prettifyError, ZodError } from "zod/v4";
import { prettifyError } from "zod/v4";

import { buildUnvalidatedEnsIndexerPublicConfig } from "../../ensindexer/config/deserialize";
import type { Unvalidated } from "../../shared/types";
import type { SerializedEnsApiPublicConfig } from "./serialized-types";
import type { EnsApiPublicConfig } from "./types";
import { makeEnsApiPublicConfigSchema } from "./zod-schemas";
import {
makeEnsApiPublicConfigSchema,
makeSerializedEnsApiPublicConfigSchema,
} from "./zod-schemas";

/**
* Deserialize a {@link EnsApiPublicConfig} object.
* Builds an unvalidated {@link EnsApiPublicConfig} object to be
* validated with {@link makeEnsApiPublicConfigSchema}.
*
* @param serializedPublicConfig - The serialized public config to build from.
* @return An unvalidated {@link EnsApiPublicConfig} object.
*/
function buildUnvalidatedEnsApiPublicConfig(
serializedPublicConfig: SerializedEnsApiPublicConfig,
): Unvalidated<EnsApiPublicConfig> {
return {
...serializedPublicConfig,
ensIndexerPublicConfig: buildUnvalidatedEnsIndexerPublicConfig(
serializedPublicConfig.ensIndexerPublicConfig,
),
};
}

/**
* Deserialize value into {@link EnsApiPublicConfig} object.
*/
export function deserializeEnsApiPublicConfig(
maybeConfig: SerializedEnsApiPublicConfig,
maybePublicConfig: Unvalidated<SerializedEnsApiPublicConfig>,
valueLabel?: string,
): EnsApiPublicConfig {
const schema = makeEnsApiPublicConfigSchema(valueLabel);
try {
return schema.parse(maybeConfig);
} catch (error) {
if (error instanceof ZodError) {
throw new Error(`Cannot deserialize EnsApiPublicConfig:\n${prettifyError(error)}\n`);
}
const parsed = makeSerializedEnsApiPublicConfigSchema(valueLabel)
.transform(buildUnvalidatedEnsApiPublicConfig)
.pipe(makeEnsApiPublicConfigSchema(valueLabel))
Comment on lines +37 to +39
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applying three-step deserialization protocol.

.safeParse(maybePublicConfig);

throw error;
if (parsed.error) {
throw new Error(`Cannot deserialize EnsApiPublicConfig:\n${prettifyError(parsed.error)}\n`);
}

return parsed.data;
}

/**
Expand Down
21 changes: 18 additions & 3 deletions packages/ensnode-sdk/src/ensapi/config/zod-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { z } from "zod/v4";

import { makeEnsIndexerPublicConfigSchema } from "../../ensindexer/config/zod-schemas";
import {
makeEnsIndexerPublicConfigSchema,
makeSerializedEnsIndexerPublicConfigSchema,
} from "../../ensindexer/config/zod-schemas";
import {
TheGraphCannotFallbackReasonSchema,
TheGraphFallbackSchema,
Expand All @@ -9,14 +12,14 @@ import {
export { TheGraphCannotFallbackReasonSchema, TheGraphFallbackSchema };

/**
* Create a Zod schema for validating a serialized ENSApiPublicConfig.
* Create a Zod schema for validating ENSApiPublicConfig.
*
* @param valueLabel - Optional label for the value being validated (used in error messages)
*/
export function makeEnsApiPublicConfigSchema(valueLabel?: string) {
const label = valueLabel ?? "ENSApiPublicConfig";

return z.strictObject({
return z.object({
version: z.string().min(1, `${label}.version must be a non-empty string`),
theGraphFallback: TheGraphFallbackSchema,
ensIndexerPublicConfig: makeEnsIndexerPublicConfigSchema(`${label}.ensIndexerPublicConfig`),
Expand All @@ -29,3 +32,15 @@ export function makeEnsApiPublicConfigSchema(valueLabel?: string) {
* @deprecated Use {@link makeEnsApiPublicConfigSchema} instead.
*/
export const makeENSApiPublicConfigSchema = makeEnsApiPublicConfigSchema;

export function makeSerializedEnsApiPublicConfigSchema(valueLabel?: string) {
const label = valueLabel ?? "ENSApiPublicConfig";

return z.object({
version: z.string().min(1, `${label}.version must be a non-empty string`),
theGraphFallback: TheGraphFallbackSchema,
ensIndexerPublicConfig: makeSerializedEnsIndexerPublicConfigSchema(
`${label}.ensIndexerPublicConfig`,
),
});
}
32 changes: 27 additions & 5 deletions packages/ensnode-sdk/src/ensindexer/config/deserialize.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,40 @@
import { prettifyError } from "zod/v4";

import type { Unvalidated } from "../../shared/types";
import type { SerializedEnsIndexerPublicConfig } from "./serialized-types";
import type { EnsIndexerPublicConfig } from "./types";
import { makeEnsIndexerPublicConfigSchema } from "./zod-schemas";
import {
makeEnsIndexerPublicConfigSchema,
makeSerializedEnsIndexerPublicConfigSchema,
} from "./zod-schemas";

/**
* Deserialize object into a {@link EnsIndexerPublicConfig} object.
* Builds an unvalidated {@link EnsIndexerPublicConfig} object to be
* validated with {@link makeEnsIndexerPublicConfigSchema}.
*
* @param serializedPublicConfig - The serialized public config to build from.
* @return An unvalidated {@link EnsIndexerPublicConfig} object.
*/
export function buildUnvalidatedEnsIndexerPublicConfig(
serializedPublicConfig: SerializedEnsIndexerPublicConfig,
): Unvalidated<EnsIndexerPublicConfig> {
return {
...serializedPublicConfig,
indexedChainIds: new Set(serializedPublicConfig.indexedChainIds),
};
}

/**
* Deserialize value into {@link EnsIndexerPublicConfig} object.
*/
export function deserializeEnsIndexerPublicConfig(
maybeConfig: SerializedEnsIndexerPublicConfig,
maybePublicConfig: Unvalidated<SerializedEnsIndexerPublicConfig>,
valueLabel?: string,
): EnsIndexerPublicConfig {
const schema = makeEnsIndexerPublicConfigSchema(valueLabel);
const parsed = schema.safeParse(maybeConfig);
const parsed = makeSerializedEnsIndexerPublicConfigSchema(valueLabel)
.transform(buildUnvalidatedEnsIndexerPublicConfig)
.pipe(makeEnsIndexerPublicConfigSchema(valueLabel))
Comment on lines +34 to +36
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applying the three-step deserialization protocol, where we use schema describing serialized data model, and then, we use validated serialized data model to build unvalidated business data model which gets validated in the last step by business-layer Zod schema.

.safeParse(maybePublicConfig);

if (parsed.error) {
throw new Error(`Cannot deserialize EnsIndexerPublicConfig:\n${prettifyError(parsed.error)}\n`);
Expand Down
51 changes: 27 additions & 24 deletions packages/ensnode-sdk/src/ensindexer/config/zod-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import { prettifyError, type ZodSafeParseResult } from "zod/v4";

import { buildUnvalidatedEnsIndexerPublicConfig } from "./deserialize";
import type { SerializedEnsIndexerPublicConfig } from "./serialized-types";
import { type EnsIndexerVersionInfo, PluginName } from "./types";
import {
makeDatabaseSchemaNameSchema,
Expand All @@ -9,6 +11,7 @@ import {
makeFullyPinnedLabelSetSchema,
makeIndexedChainIdsSchema,
makePluginsListSchema,
makeSerializedEnsIndexerPublicConfigSchema,
} from "./zod-schemas";

describe("ENSIndexer: Config", () => {
Expand Down Expand Up @@ -56,16 +59,16 @@ describe("ENSIndexer: Config", () => {
});

it("can parse indexed chain ID values", () => {
expect(makeIndexedChainIdsSchema().parse([1, 10, 8543])).toStrictEqual(
expect(makeIndexedChainIdsSchema().parse(new Set([1, 10, 8543]))).toStrictEqual(
new Set([1, 10, 8543]),
);

expect(formatParseError(makeIndexedChainIdsSchema().safeParse("1,10"))).toContain(
"Indexed Chain IDs must be an array",
"Indexed Chain IDs must be a set",
);

expect(formatParseError(makeIndexedChainIdsSchema().safeParse([]))).toContain(
"Indexed Chain IDs list must include at least one element",
expect(formatParseError(makeIndexedChainIdsSchema().safeParse(new Set([])))).toContain(
"Indexed Chain IDs must be a set with at least one chain ID.",
);
});

Expand Down Expand Up @@ -167,38 +170,38 @@ describe("ENSIndexer: Config", () => {
ensNormalize: "1.11.1",
ensRainbow: "0.32.0",
ensRainbowSchema: 2,
} satisfies EnsIndexerVersionInfo,
};
},
} satisfies SerializedEnsIndexerPublicConfig;

const parsedConfig = makeEnsIndexerPublicConfigSchema().parse(validConfig);
const parsedConfig = makeSerializedEnsIndexerPublicConfigSchema().parse(validConfig);

// The schema transforms URLs and arrays, so we need to check the transformed values
expect(parsedConfig.indexedChainIds).toBeInstanceOf(Set);
expect(Array.from(parsedConfig.indexedChainIds)).toEqual([1]);
expect(parsedConfig.labelSet).toEqual(validConfig.labelSet);
expect(parsedConfig.isSubgraphCompatible).toBe(validConfig.isSubgraphCompatible);
expect(parsedConfig.namespace).toBe(validConfig.namespace);
expect(parsedConfig.plugins).toEqual(validConfig.plugins);
expect(parsedConfig.databaseSchemaName).toBe(validConfig.databaseSchemaName);
expect(parsedConfig.versionInfo).toEqual(validConfig.versionInfo);
// Verify that the parsed config has the expected values and types
expect(parsedConfig).toStrictEqual(validConfig);

// Test invalid labelSetId
expect(
formatParseError(
makeEnsIndexerPublicConfigSchema().safeParse({
...validConfig,
labelSet: { ...validConfig.labelSet, labelSetId: "" },
}),
makeEnsIndexerPublicConfigSchema().safeParse(
buildUnvalidatedEnsIndexerPublicConfig({
...validConfig,
labelSet: { ...validConfig.labelSet, labelSetId: "" },
}),
),
),
).toContain("labelSet.labelSetId must be 1-50 characters long");

// Test invalid labelSetVersion
expect(
formatParseError(
makeEnsIndexerPublicConfigSchema().safeParse({
...validConfig,
labelSet: { ...validConfig.labelSet, labelSetVersion: "not-a-number" },
}),
makeEnsIndexerPublicConfigSchema().safeParse(
buildUnvalidatedEnsIndexerPublicConfig({
...validConfig,
labelSet: {
...validConfig.labelSet,
labelSetVersion: "not-a-number" as unknown as number,
},
}),
),
),
).toContain("labelSet.labelSetVersion must be an integer");
});
Expand Down
33 changes: 28 additions & 5 deletions packages/ensnode-sdk/src/ensindexer/config/zod-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,18 @@ import { invariant_ensDbVersionIsSameAsEnsIndexerVersion } from "./validations";
* Makes a schema for parsing {@link IndexedChainIds}.
*/
export const makeIndexedChainIdsSchema = (valueLabel: string = "Indexed Chain IDs") =>
z.set(makeChainIdSchema(valueLabel), { error: `${valueLabel} must be a set` }).min(1, {
error: `${valueLabel} must be a set with at least one chain ID.`,
});

export const makeSerializedIndexedChainIdsSchema = (valueLabel: string = "Indexed Chain IDs") =>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makeSerializedIndexedChainIdsSchema is required for makeSerializedEnsIndexerPublicConfigSchema.

z
.array(makeChainIdSchema(valueLabel), {
error: `${valueLabel} must be an array.`,
})
.min(1, { error: `${valueLabel} list must include at least one element.` })
.transform((v) => new Set(v));
.min(1, {
error: `${valueLabel} must be an array with at least one chain ID.`,
});

/**
* Makes a schema for parsing a list of strings that (for future-proofing)
Expand Down Expand Up @@ -91,7 +97,7 @@ export const makeLabelSetIdSchema = (valueLabel: string) => {
*/
export const makeLabelSetVersionSchema = (valueLabel: string) => {
return z.coerce
.number({ error: `${valueLabel} must be an integer.` })
.number<number>({ error: `${valueLabel} must be an integer.` })
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type convenience update — we know the only valid input is value representing a number, otherwise, the makeNonNegativeIntegerSchema() schema would fail.

.pipe(makeNonNegativeIntegerSchema(valueLabel));
};

Expand Down Expand Up @@ -170,11 +176,13 @@ export const makeEnsIndexerPublicConfigSchema = (valueLabel: string = "ENSIndexe
.object({
labelSet: makeFullyPinnedLabelSetSchema(`${valueLabel}.labelSet`),
indexedChainIds: makeIndexedChainIdsSchema(`${valueLabel}.indexedChainIds`),
isSubgraphCompatible: z.boolean({ error: `${valueLabel}.isSubgraphCompatible` }),
isSubgraphCompatible: z.boolean({
error: `${valueLabel}.isSubgraphCompatible must be a boolean value.`,
}),
namespace: makeENSNamespaceIdSchema(`${valueLabel}.namespace`),
plugins: makePluginsListSchema(`${valueLabel}.plugins`),
databaseSchemaName: makeDatabaseSchemaNameSchema(`${valueLabel}.databaseSchemaName`),
versionInfo: makeENSIndexerVersionInfoSchema(`${valueLabel}.versionInfo`),
versionInfo: makeEnsIndexerVersionInfoSchema(`${valueLabel}.versionInfo`),
})
/**
* Validations
Expand All @@ -189,3 +197,18 @@ export const makeEnsIndexerPublicConfigSchema = (valueLabel: string = "ENSIndexe
* @deprecated Use {@link makeEnsIndexerPublicConfigSchema} instead.
*/
export const makeENSIndexerPublicConfigSchema = makeEnsIndexerPublicConfigSchema;

export const makeSerializedEnsIndexerPublicConfigSchema = (
valueLabel: string = "Serialized ENSIndexerPublicConfig",
) =>
z.object({
labelSet: makeFullyPinnedLabelSetSchema(`${valueLabel}.labelSet`),
indexedChainIds: makeSerializedIndexedChainIdsSchema(`${valueLabel}.indexedChainIds`),
isSubgraphCompatible: z.boolean({
error: `${valueLabel}.isSubgraphCompatible must be a boolean value.`,
}),
namespace: makeENSNamespaceIdSchema(`${valueLabel}.namespace`),
plugins: makePluginsListSchema(`${valueLabel}.plugins`),
databaseSchemaName: makeDatabaseSchemaNameSchema(`${valueLabel}.databaseSchemaName`),
versionInfo: makeEnsIndexerVersionInfoSchema(`${valueLabel}.versionInfo`),
});