Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/chilly-plums-lose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ensapi": minor
---

Refactored HTTP handlers for Indexing Status API and Config API to rely solely on ENSDb Client for data.
5 changes: 5 additions & 0 deletions .changeset/fancy-cloths-fly.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ensnode/ensnode-sdk": minor
---

Added `validateEnsApiPublicConfig` function.
11 changes: 10 additions & 1 deletion apps/ensapi/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,18 @@ ENSINDEXER_URL=http://localhost:42069
# It should be in the format of `postgresql://<username>:<password>@<host>:<port>/<database>`
Copy link
Member

Choose a reason for hiding this comment

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

Several lines above there's still an ENSINDEXER_URL defined. Shouldn't that be getting removed now?

#
# See https://ensnode.io/ensindexer/usage/configuration/ for additional information.
# NOTE that ENSApi does NOT need to define DATABASE_SCHEMA, as it is inferred from the connected ENSIndexer's Config.
DATABASE_URL=postgresql://dbuser:abcd1234@localhost:5432/my_database

# ENSDb: schema name
# Required. This is a namespace for the tables that the ENSDbClient for ENSApi will apply to read ENSDb data.
Copy link
Member

Choose a reason for hiding this comment

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

Suggest avoiding the use of the word "namespace" here -- it's overloading terminology too much with ENS namespace.

# Must be set to an existing namespace in the connected ENSDb at DATABASE_URL.
DATABASE_SCHEMA=public

# ENS Namespace Configuration
# Required. Must be an ENS namespace's Identifier such as mainnet, sepolia, or ens-test-env.
# (see `@ensnode/datasources` for available options).
NAMESPACE=mainnet

# ENSApi: RPC Configuration
# Required. ENSApi requires an HTTP RPC to the connected ENSIndexer's ENS Root Chain, which depends
Copy link
Member

Choose a reason for hiding this comment

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

Please invest time in carefully crafting a refinement to how this idea is documented. We need our language here to be precisely aligned with the new architecture refinements.

# on ENSIndexer's NAMESPACE (ex: mainnet, sepolia, ens-test-env). This ENS Root Chain RPC
Expand Down
29 changes: 12 additions & 17 deletions apps/ensapi/src/cache/indexing-status.cache.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,21 @@
import config from "@/config";

import {
type CrossChainIndexingStatusSnapshot,
ENSNodeClient,
IndexingStatusResponseCodes,
SWRCache,
} from "@ensnode/ensnode-sdk";
import { type CrossChainIndexingStatusSnapshot, SWRCache } from "@ensnode/ensnode-sdk";

import { ensDbClient } from "@/lib/ensdb-client/singleton";
import { makeLogger } from "@/lib/logger";

const logger = makeLogger("indexing-status.cache");
const client = new ENSNodeClient({ url: config.ensIndexerUrl });

export const indexingStatusCache = new SWRCache<CrossChainIndexingStatusSnapshot>({
fn: async (_cachedResult) =>
client
.indexingStatus() // fetch a new indexing status snapshot
.then((response) => {
if (response.responseCode !== IndexingStatusResponseCodes.Ok) {
// An indexing status response was successfully fetched, but the response code contained within the response was not 'ok'.
ensDbClient
.getIndexingStatusSnapshot() // get the latest indexing status snapshot
.then((snapshot) => {
if (snapshot === undefined) {
// An indexing status snapshot has not been found in ENSDb yet.
// This might happen during application startup, i.e. when ENSDb
// has not yet been populated with the first snapshot.
// Therefore, throw an error to trigger the subsequent `.catch` handler.
throw new Error("Received Indexing Status response with responseCode other than 'ok'.");
throw new Error("Indexing Status snapshot not found in ENSDb yet.");
}

logger.info("Fetched Indexing Status to be cached");
Expand All @@ -29,10 +24,10 @@ export const indexingStatusCache = new SWRCache<CrossChainIndexingStatusSnapshot
// Therefore, return it so that this current invocation of `readCache` will:
// - Replace the currently cached value (if any) with this new value.
// - Return this non-null value.
return response.realtimeProjection.snapshot;
return snapshot;
})
.catch((error) => {
// Either the indexing status snapshot fetch failed, or the indexing status response was not 'ok'.
// Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
// Therefore, throw an error so that this current invocation of `readCache` will:
// - Reject the newly fetched response (if any) such that it won't be cached.
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
Expand Down
182 changes: 19 additions & 163 deletions apps/ensapi/src/config/config.schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@ import packageJson from "@/../package.json" with { type: "json" };

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
type ENSIndexerPublicConfig,
PluginName,
serializeENSIndexerPublicConfig,
} from "@ensnode/ensnode-sdk";
import type { RpcConfig } from "@ensnode/ensnode-sdk/internal";

import { buildConfigFromEnvironment, buildEnsApiPublicConfig } from "@/config/config.schema";
import { buildConfigFromEnvironment } from "@/config/config.schema";
import { ENSApi_DEFAULT_PORT } from "@/config/defaults";
import type { EnsApiEnvironment } from "@/config/environment";
import logger from "@/lib/logger";
Expand All @@ -25,54 +20,24 @@ const VALID_RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/1234";

const BASE_ENV = {
DATABASE_URL: "postgresql://user:password@localhost:5432/mydb",
DATABASE_SCHEMA: "ensapi",
ENSINDEXER_URL: "http://localhost:42069",
NAMESPACE: "mainnet",
RPC_URL_1: VALID_RPC_URL,
} satisfies EnsApiEnvironment;

const ENSINDEXER_PUBLIC_CONFIG = {
namespace: "mainnet",
databaseSchemaName: "ensapi",
ensRainbowPublicConfig: {
version: packageJson.version,
labelSet: { labelSetId: "subgraph", highestLabelSetVersion: 0 },
recordsCount: 100,
},
indexedChainIds: new Set([1]),
isSubgraphCompatible: false,
labelSet: { labelSetId: "subgraph", labelSetVersion: 0 },
plugins: [PluginName.Subgraph],
versionInfo: {
ensDb: packageJson.version,
ensIndexer: packageJson.version,
ensNormalize: "1.1.1",
nodejs: "1.1.1",
ponder: "1.1.1",
},
} satisfies ENSIndexerPublicConfig;

const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);

describe("buildConfigFromEnvironment", () => {
afterEach(() => {
mockFetch.mockReset();
});

it("returns a valid config object using environment variables", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)),
});
it("returns a valid config object using environment variables", () => {
const result = buildConfigFromEnvironment(BASE_ENV);

await expect(buildConfigFromEnvironment(BASE_ENV)).resolves.toStrictEqual({
expect(result).toStrictEqual({
version: packageJson.version,
port: ENSApi_DEFAULT_PORT,
databaseUrl: BASE_ENV.DATABASE_URL,
databaseSchemaName: BASE_ENV.DATABASE_SCHEMA,
ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL),
theGraphApiKey: undefined,

ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
namespace: BASE_ENV.NAMESPACE,
rpcConfigs: new Map([
[
1,
Expand All @@ -86,15 +51,10 @@ describe("buildConfigFromEnvironment", () => {
});
});

it("parses CUSTOM_REFERRAL_PROGRAM_EDITIONS as a URL object", async () => {
it("parses CUSTOM_REFERRAL_PROGRAM_EDITIONS as a URL object", () => {
const customUrl = "https://example.com/editions.json";

mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)),
});

const config = await buildConfigFromEnvironment({
const config = buildConfigFromEnvironment({
...BASE_ENV,
CUSTOM_REFERRAL_PROGRAM_EDITIONS: customUrl,
});
Expand All @@ -116,16 +76,13 @@ describe("buildConfigFromEnvironment", () => {

const TEST_ENV: EnsApiEnvironment = {
DATABASE_URL: BASE_ENV.DATABASE_URL,
DATABASE_SCHEMA: BASE_ENV.DATABASE_SCHEMA,
ENSINDEXER_URL: BASE_ENV.ENSINDEXER_URL,
NAMESPACE: "mainnet",
};

it("logs error and exits when CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)),
});

await buildConfigFromEnvironment({
it("logs error and exits when CUSTOM_REFERRAL_PROGRAM_EDITIONS is not a valid URL", () => {
buildConfigFromEnvironment({
...TEST_ENV,
CUSTOM_REFERRAL_PROGRAM_EDITIONS: "not-a-url",
});
Expand All @@ -136,13 +93,8 @@ describe("buildConfigFromEnvironment", () => {
expect(process.exit).toHaveBeenCalledWith(1);
});

it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)),
});

await buildConfigFromEnvironment({
it("logs error message when QuickNode RPC config was partially configured (missing endpoint name)", () => {
buildConfigFromEnvironment({
...TEST_ENV,
QUICKNODE_API_KEY: "my-api-key",
});
Expand All @@ -156,13 +108,8 @@ describe("buildConfigFromEnvironment", () => {
expect(process.exit).toHaveBeenCalledWith(1);
});

it("logs error message when QuickNode RPC config was partially configured (missing API key)", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(serializeENSIndexerPublicConfig(ENSINDEXER_PUBLIC_CONFIG)),
});

await buildConfigFromEnvironment({
it("logs error message when QuickNode RPC config was partially configured (missing API key)", () => {
buildConfigFromEnvironment({
...TEST_ENV,
QUICKNODE_ENDPOINT_NAME: "my-endpoint-name",
});
Expand All @@ -177,94 +124,3 @@ describe("buildConfigFromEnvironment", () => {
});
});
});

describe("buildEnsApiPublicConfig", () => {
it("returns a valid ENSApi public config with correct structure", () => {
const mockConfig = {
port: ENSApi_DEFAULT_PORT,
databaseUrl: BASE_ENV.DATABASE_URL,
ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL),
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
rpcConfigs: new Map([
[
1,
{
httpRPCs: [new URL(VALID_RPC_URL)],
websocketRPC: undefined,
} satisfies RpcConfig,
],
]),
customReferralProgramEditionConfigSetUrl: undefined,
};

const result = buildEnsApiPublicConfig(mockConfig);

expect(result).toStrictEqual({
version: packageJson.version,
theGraphFallback: {
canFallback: false,
reason: "not-subgraph-compatible",
},
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
});
});

it("preserves the complete ENSIndexer public config structure", () => {
const mockConfig = {
port: ENSApi_DEFAULT_PORT,
databaseUrl: BASE_ENV.DATABASE_URL,
ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL),
ensIndexerPublicConfig: ENSINDEXER_PUBLIC_CONFIG,
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
rpcConfigs: new Map(),
customReferralProgramEditionConfigSetUrl: undefined,
};

const result = buildEnsApiPublicConfig(mockConfig);

// Verify that all ENSIndexer public config fields are preserved
expect(result.ensIndexerPublicConfig.namespace).toBe(ENSINDEXER_PUBLIC_CONFIG.namespace);
expect(result.ensIndexerPublicConfig.plugins).toEqual(ENSINDEXER_PUBLIC_CONFIG.plugins);
expect(result.ensIndexerPublicConfig.versionInfo).toEqual(ENSINDEXER_PUBLIC_CONFIG.versionInfo);
expect(result.ensIndexerPublicConfig.indexedChainIds).toEqual(
ENSINDEXER_PUBLIC_CONFIG.indexedChainIds,
);
expect(result.ensIndexerPublicConfig.isSubgraphCompatible).toBe(
ENSINDEXER_PUBLIC_CONFIG.isSubgraphCompatible,
);
expect(result.ensIndexerPublicConfig.labelSet).toEqual(ENSINDEXER_PUBLIC_CONFIG.labelSet);
expect(result.ensIndexerPublicConfig.databaseSchemaName).toBe(
ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
);
});

it("includes the theGraphFallback and redacts api key", () => {
const mockConfig = {
port: ENSApi_DEFAULT_PORT,
databaseUrl: BASE_ENV.DATABASE_URL,
ensIndexerUrl: new URL(BASE_ENV.ENSINDEXER_URL),
ensIndexerPublicConfig: {
...ENSINDEXER_PUBLIC_CONFIG,
plugins: ["subgraph"],
isSubgraphCompatible: true,
},
namespace: ENSINDEXER_PUBLIC_CONFIG.namespace,
databaseSchemaName: ENSINDEXER_PUBLIC_CONFIG.databaseSchemaName,
rpcConfigs: new Map(),
customReferralProgramEditionConfigSetUrl: undefined,
theGraphApiKey: "secret-api-key",
};

const result = buildEnsApiPublicConfig(mockConfig);

expect(result.theGraphFallback.canFallback).toBe(true);
// discriminate the type...
if (!result.theGraphFallback.canFallback) throw new Error("never");

// shouldn't have the secret-api-key in the url
expect(result.theGraphFallback.url).not.toMatch(/secret-api-key/gi);
});
});
Loading