Skip to content

Commit 756cce0

Browse files
authored
Improve openapi generation (#1808)
1 parent aaa471f commit 756cce0

21 files changed

+632
-485
lines changed

apps/ensapi/src/app.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import packageJson from "@/../package.json" with { type: "json" };
2+
3+
import { otel } from "@hono/otel";
4+
import { cors } from "hono/cors";
5+
import { html } from "hono/html";
6+
7+
import { errorResponse } from "@/lib/handlers/error-response";
8+
import { createApp } from "@/lib/hono-factory";
9+
import logger from "@/lib/logger";
10+
import { generateOpenApi31Document } from "@/openapi-document";
11+
12+
import realtimeApi from "./handlers/api/meta/realtime-api";
13+
import apiRouter from "./handlers/api/router";
14+
import ensanalyticsApi from "./handlers/ensanalytics/ensanalytics-api";
15+
import ensanalyticsApiV1 from "./handlers/ensanalytics/ensanalytics-api-v1";
16+
import subgraphApi from "./handlers/subgraph/subgraph-api";
17+
18+
const app = createApp();
19+
20+
// set the X-ENSNode-Version response header to the current version
21+
app.use(async (ctx, next) => {
22+
ctx.header("x-ensnode-version", packageJson.version);
23+
return next();
24+
});
25+
26+
// use CORS middleware
27+
app.use(cors({ origin: "*" }));
28+
29+
// include automatic OpenTelemetry instrumentation for incoming requests
30+
app.use(otel());
31+
32+
// host welcome page
33+
app.get("/", (c) =>
34+
c.html(html`
35+
<!DOCTYPE html>
36+
<html lang="en">
37+
<head>
38+
<meta charset="UTF-8">
39+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
40+
<title>ENSApi</title>
41+
</head>
42+
<body>
43+
<h1>Hello, World!</h1>
44+
<p>You've reached the root of an ENSApi instance. You might be looking for the <a href="https://ensnode.io/docs">ENSNode documentation</a>.</p>
45+
</body>
46+
</html>
47+
`),
48+
);
49+
50+
// use ENSNode HTTP API at /api
51+
app.route("/api", apiRouter);
52+
53+
// use Subgraph GraphQL API at /subgraph
54+
app.route("/subgraph", subgraphApi);
55+
56+
// use ENSAnalytics API at /ensanalytics (v0, implicit)
57+
app.route("/ensanalytics", ensanalyticsApi);
58+
59+
// use ENSAnalytics API v1 at /v1/ensanalytics
60+
app.route("/v1/ensanalytics", ensanalyticsApiV1);
61+
62+
// use Am I Realtime API at /amirealtime
63+
// NOTE: this is legacy endpoint and will be deleted in future. one should use /api/realtime instead
64+
app.route("/amirealtime", realtimeApi);
65+
66+
// generate and return OpenAPI 3.1 document
67+
app.get("/openapi.json", (c) => {
68+
return c.json(generateOpenApi31Document(app));
69+
});
70+
71+
app.get("/health", async (c) => {
72+
return c.json({ message: "fallback ok" });
73+
});
74+
75+
// log hono errors to console
76+
app.onError((error, ctx) => {
77+
logger.error(error);
78+
return errorResponse(ctx, "Internal Server Error");
79+
});
80+
81+
export default app;

apps/ensapi/src/cache/indexing-status.cache.ts

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,53 @@ import { EnsNodeMetadataKeys } from "@ensnode/ensdb-sdk";
22
import { type CrossChainIndexingStatusSnapshot, SWRCache } from "@ensnode/ensnode-sdk";
33

44
import { ensDbClient } from "@/lib/ensdb/singleton";
5+
import { lazyProxy } from "@/lib/lazy";
56
import { makeLogger } from "@/lib/logger";
67

78
const logger = makeLogger("indexing-status.cache");
89

9-
export const indexingStatusCache = new SWRCache<CrossChainIndexingStatusSnapshot>({
10-
fn: async (_cachedResult) =>
11-
ensDbClient
12-
.getIndexingStatusSnapshot() // get the latest indexing status snapshot
13-
.then((snapshot) => {
14-
if (snapshot === undefined) {
15-
// An indexing status snapshot has not been found in ENSDb yet.
16-
// This might happen during application startup, i.e. when ENSDb
17-
// has not yet been populated with the first snapshot.
18-
// Therefore, throw an error to trigger the subsequent `.catch` handler.
19-
throw new Error("Indexing Status snapshot not found in ENSDb yet.");
20-
}
10+
// lazyProxy defers construction until first use so that this module can be
11+
// imported without env vars being present (e.g. during OpenAPI generation).
12+
// SWRCache with proactivelyInitialize:true starts background polling immediately
13+
// on construction, which would trigger ensDbClient before env vars are available.
14+
export const indexingStatusCache = lazyProxy<SWRCache<CrossChainIndexingStatusSnapshot>>(
15+
() =>
16+
new SWRCache<CrossChainIndexingStatusSnapshot>({
17+
fn: async (_cachedResult) =>
18+
ensDbClient
19+
.getIndexingStatusSnapshot() // get the latest indexing status snapshot
20+
.then((snapshot) => {
21+
if (snapshot === undefined) {
22+
// An indexing status snapshot has not been found in ENSDb yet.
23+
// This might happen during application startup, i.e. when ENSDb
24+
// has not yet been populated with the first snapshot.
25+
// Therefore, throw an error to trigger the subsequent `.catch` handler.
26+
throw new Error("Indexing Status snapshot not found in ENSDb yet.");
27+
}
2128

22-
// The indexing status snapshot has been fetched and successfully validated for caching.
23-
// Therefore, return it so that this current invocation of `readCache` will:
24-
// - Replace the currently cached value (if any) with this new value.
25-
// - Return this non-null value.
26-
return snapshot;
27-
})
28-
.catch((error) => {
29-
// Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
30-
// Therefore, throw an error so that this current invocation of `readCache` will:
31-
// - Reject the newly fetched response (if any) such that it won't be cached.
32-
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
33-
logger.error(
34-
error,
35-
`Error occurred while loading Indexing Status snapshot record from ENSNode Metadata table in ENSDb. ` +
36-
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.EnsIndexerIndexingStatus}"). ` +
37-
`The cached indexing status snapshot (if any) will not be updated.`,
38-
);
39-
throw error;
40-
}),
41-
// We need to refresh the indexing status cache very frequently.
42-
// ENSDb won't have issues handling this frequency of queries.
43-
ttl: 1, // 1 second
44-
proactiveRevalidationInterval: 1, // 1 second
45-
proactivelyInitialize: true,
46-
});
29+
// The indexing status snapshot has been fetched and successfully validated for caching.
30+
// Therefore, return it so that this current invocation of `readCache` will:
31+
// - Replace the currently cached value (if any) with this new value.
32+
// - Return this non-null value.
33+
return snapshot;
34+
})
35+
.catch((error) => {
36+
// Either the indexing status snapshot fetch failed, or the indexing status snapshot was not found in ENSDb yet.
37+
// Therefore, throw an error so that this current invocation of `readCache` will:
38+
// - Reject the newly fetched response (if any) such that it won't be cached.
39+
// - Return the most recently cached value from prior invocations, or `null` if no prior invocation successfully cached a value.
40+
logger.error(
41+
error,
42+
`Error occurred while loading Indexing Status snapshot record from ENSNode Metadata table in ENSDb. ` +
43+
`Where clause applied: ("ensIndexerSchemaName" = "${ensDbClient.ensIndexerSchemaName}", "key" = "${EnsNodeMetadataKeys.EnsIndexerIndexingStatus}"). ` +
44+
`The cached indexing status snapshot (if any) will not be updated.`,
45+
);
46+
throw error;
47+
}),
48+
// We need to refresh the indexing status cache very frequently.
49+
// ENSDb won't have issues handling this frequency of queries.
50+
ttl: 1, // 1 second
51+
proactiveRevalidationInterval: 1, // 1 second
52+
proactivelyInitialize: true,
53+
}),
54+
);

apps/ensapi/src/cache/referral-program-edition-set.cache.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { minutesToSeconds } from "date-fns";
1010

1111
import { type CachedResult, SWRCache } from "@ensnode/ensnode-sdk";
1212

13+
import { lazyProxy } from "@/lib/lazy";
1314
import { makeLogger } from "@/lib/logger";
1415

1516
const logger = makeLogger("referral-program-edition-set-cache");
@@ -63,6 +64,8 @@ async function loadReferralProgramEditionConfigSet(
6364
return editionConfigSet;
6465
}
6566

67+
type ReferralProgramEditionConfigSetCache = SWRCache<ReferralProgramEditionConfigSet>;
68+
6669
/**
6770
* SWR Cache for the referral program edition config set.
6871
*
@@ -74,10 +77,15 @@ async function loadReferralProgramEditionConfigSet(
7477
* - proactiveRevalidationInterval: undefined - No proactive revalidation
7578
* - proactivelyInitialize: true - Load immediately on startup
7679
*/
77-
export const referralProgramEditionConfigSetCache = new SWRCache<ReferralProgramEditionConfigSet>({
78-
fn: loadReferralProgramEditionConfigSet,
79-
ttl: Number.POSITIVE_INFINITY,
80-
errorTtl: minutesToSeconds(1),
81-
proactiveRevalidationInterval: undefined,
82-
proactivelyInitialize: true,
83-
});
80+
// lazyProxy defers construction until first use so that this module can be
81+
// imported without env vars being present (e.g. during OpenAPI generation).
82+
export const referralProgramEditionConfigSetCache = lazyProxy<ReferralProgramEditionConfigSetCache>(
83+
() =>
84+
new SWRCache<ReferralProgramEditionConfigSet>({
85+
fn: loadReferralProgramEditionConfigSet,
86+
ttl: Number.POSITIVE_INFINITY,
87+
errorTtl: minutesToSeconds(1),
88+
proactiveRevalidationInterval: undefined,
89+
proactivelyInitialize: true,
90+
}),
91+
);

apps/ensapi/src/cache/referrer-leaderboard.cache.ts

Lines changed: 57 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,23 @@ import {
1919
} from "@ensnode/ensnode-sdk";
2020

2121
import { getReferrerLeaderboard } from "@/lib/ensanalytics/referrer-leaderboard";
22+
import { lazyProxy } from "@/lib/lazy";
2223
import { makeLogger } from "@/lib/logger";
2324

2425
import { indexingStatusCache } from "./indexing-status.cache";
2526

2627
const logger = makeLogger("referrer-leaderboard-cache.cache");
2728

28-
const rules = buildReferralProgramRules(
29-
ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE,
30-
ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS,
31-
ENS_HOLIDAY_AWARDS_START_DATE,
32-
ENS_HOLIDAY_AWARDS_END_DATE,
33-
getEthnamesSubregistryId(config.namespace),
29+
// lazyProxy defers construction until first use so that this module can be
30+
// imported without env vars being present (e.g. during OpenAPI generation).
31+
const rules = lazyProxy(() =>
32+
buildReferralProgramRules(
33+
ENS_HOLIDAY_AWARDS_TOTAL_AWARD_POOL_VALUE,
34+
ENS_HOLIDAY_AWARDS_MAX_QUALIFIED_REFERRERS,
35+
ENS_HOLIDAY_AWARDS_START_DATE,
36+
ENS_HOLIDAY_AWARDS_END_DATE,
37+
getEthnamesSubregistryId(config.namespace),
38+
),
3439
);
3540

3641
/**
@@ -45,46 +50,53 @@ const supportedOmnichainIndexingStatuses: OmnichainIndexingStatusId[] = [
4550
OmnichainIndexingStatusIds.Completed,
4651
];
4752

48-
export const referrerLeaderboardCache = new SWRCache<ReferrerLeaderboard>({
49-
fn: async (_cachedResult) => {
50-
const indexingStatus = await indexingStatusCache.read();
51-
if (indexingStatus instanceof Error) {
52-
throw new Error(
53-
"Unable to generate referrer leaderboard. indexingStatusCache must have been successfully initialized.",
54-
);
55-
}
53+
type ReferrerLeaderboardCache = SWRCache<ReferrerLeaderboard>;
54+
55+
// lazyProxy defers construction until first use so that this module can be
56+
// imported without env vars being present (e.g. during OpenAPI generation).
57+
export const referrerLeaderboardCache = lazyProxy<ReferrerLeaderboardCache>(
58+
() =>
59+
new SWRCache<ReferrerLeaderboard>({
60+
fn: async (_cachedResult) => {
61+
const indexingStatus = await indexingStatusCache.read();
62+
if (indexingStatus instanceof Error) {
63+
throw new Error(
64+
"Unable to generate referrer leaderboard. indexingStatusCache must have been successfully initialized.",
65+
);
66+
}
5667

57-
const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus;
58-
if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) {
59-
throw new Error(
60-
`Unable to generate referrer leaderboard. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")} to generate a referrer leaderboard.`,
61-
);
62-
}
68+
const omnichainIndexingStatus = indexingStatus.omnichainSnapshot.omnichainStatus;
69+
if (!supportedOmnichainIndexingStatuses.includes(omnichainIndexingStatus)) {
70+
throw new Error(
71+
`Unable to generate referrer leaderboard. Omnichain indexing status is currently ${omnichainIndexingStatus} but must be ${supportedOmnichainIndexingStatuses.join(" or ")} to generate a referrer leaderboard.`,
72+
);
73+
}
6374

64-
const latestIndexedBlockRef = getLatestIndexedBlockRef(
65-
indexingStatus,
66-
rules.subregistryId.chainId,
67-
);
68-
if (latestIndexedBlockRef === null) {
69-
throw new Error(
70-
`Unable to generate referrer leaderboard. Latest indexed block ref for chain ${rules.subregistryId.chainId} is null.`,
71-
);
72-
}
75+
const latestIndexedBlockRef = getLatestIndexedBlockRef(
76+
indexingStatus,
77+
rules.subregistryId.chainId,
78+
);
79+
if (latestIndexedBlockRef === null) {
80+
throw new Error(
81+
`Unable to generate referrer leaderboard. Latest indexed block ref for chain ${rules.subregistryId.chainId} is null.`,
82+
);
83+
}
7384

74-
logger.info(`Building referrer leaderboard with rules:\n${JSON.stringify(rules, null, 2)}`);
85+
logger.info(`Building referrer leaderboard with rules:\n${JSON.stringify(rules, null, 2)}`);
7586

76-
try {
77-
const result = await getReferrerLeaderboard(rules, latestIndexedBlockRef.timestamp);
78-
logger.info(
79-
`Successfully built referrer leaderboard with ${result.referrers.size} referrers from indexed data`,
80-
);
81-
return result;
82-
} catch (error) {
83-
logger.error({ error }, "Failed to build referrer leaderboard");
84-
throw error;
85-
}
86-
},
87-
ttl: minutesToSeconds(1),
88-
proactiveRevalidationInterval: minutesToSeconds(2),
89-
proactivelyInitialize: true,
90-
});
87+
try {
88+
const result = await getReferrerLeaderboard(rules, latestIndexedBlockRef.timestamp);
89+
logger.info(
90+
`Successfully built referrer leaderboard with ${result.referrers.size} referrers from indexed data`,
91+
);
92+
return result;
93+
} catch (error) {
94+
logger.error({ error }, "Failed to build referrer leaderboard");
95+
throw error;
96+
}
97+
},
98+
ttl: minutesToSeconds(1),
99+
proactiveRevalidationInterval: minutesToSeconds(2),
100+
proactivelyInitialize: true,
101+
}),
102+
);

apps/ensapi/src/config/config.singleton.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,11 @@ describe("ensdb singleton bootstrap", () => {
2222
});
2323

2424
it("constructs EnsDbReader from real env wiring without errors", async () => {
25-
const { EnsDbReader } = await import("@ensnode/ensdb-sdk");
2625
const { ensDbClient, ensDb, ensIndexerSchema } = await import("@/lib/ensdb/singleton");
2726

28-
expect(ensDbClient).toBeInstanceOf(EnsDbReader);
27+
// ensDbClient is a lazyProxy — construction is deferred until first property access.
28+
// Accessing a property triggers EnsDbReader construction; verify it succeeds.
29+
expect(ensDbClient.ensIndexerSchemaName).toBe(VALID_SCHEMA_NAME);
2930
expect(ensDb).toBeDefined();
3031
expect(ensIndexerSchema).toBeDefined();
3132
});
@@ -37,7 +38,10 @@ describe("ensdb singleton bootstrap", () => {
3738
const { default: logger } = await import("@/lib/logger");
3839

3940
vi.stubEnv("DATABASE_URL", "");
40-
await expect(import("@/lib/ensdb/singleton")).rejects.toThrow("process.exit");
41+
// ensDbClient is a lazyProxy — import succeeds but first property access triggers construction,
42+
// which calls buildEnsDbConfigFromEnvironment and exits on invalid config.
43+
const { ensDbClient } = await import("@/lib/ensdb/singleton");
44+
expect(() => ensDbClient.ensDb).toThrow("process.exit");
4145

4246
expect(logger.error).toHaveBeenCalled();
4347
expect(mockExit).toHaveBeenCalledWith(1);
@@ -51,7 +55,10 @@ describe("ensdb singleton bootstrap", () => {
5155
const { default: logger } = await import("@/lib/logger");
5256

5357
vi.stubEnv("ENSINDEXER_SCHEMA_NAME", "");
54-
await expect(import("@/lib/ensdb/singleton")).rejects.toThrow("process.exit");
58+
// ensDbClient is a lazyProxy — import succeeds but first property access triggers construction,
59+
// which calls buildEnsDbConfigFromEnvironment and exits on invalid config.
60+
const { ensDbClient } = await import("@/lib/ensdb/singleton");
61+
expect(() => ensDbClient.ensDb).toThrow("process.exit");
5562

5663
expect(logger.error).toHaveBeenCalled();
5764
expect(mockExit).toHaveBeenCalledWith(1);

0 commit comments

Comments
 (0)