Skip to content

Commit ea6e868

Browse files
Add utility lookup by EIA ID
Adds GET /api/v1/utilities/by-eia-id/{eiaId}, a sibling to the existing slug-based detail route for consumers that hold canonical EIA Utility IDs. The new route mirrors the slug route response shape and supports the same ?include=iso,rto,ba and ?fields sparse-fieldset semantics. It excludes soft-deleted utilities and returns a normal public API 404 when no matching EIA ID exists. Also adds shared edge caching headers to utility detail responses: public, s-maxage=3600, stale-while-revalidate=86400. Validation: npm run build includes /api/v1/utilities/by-eia-id/[eiaId].
1 parent 5b8532d commit ea6e868

2 files changed

Lines changed: 111 additions & 2 deletions

File tree

app/api/v1/utilities/[slug]/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { balancingAuthorities, isos, rtos, utilities } from "@/lib/db/schema";
1111
// GET /api/v1/utilities/[slug] — Get utility by slug with optional includes
1212
// ---------------------------------------------------------------------------
1313

14+
const CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400";
15+
1416
async function handleGet(req: Request, ctx: RouteContext) {
1517
const slug = ctx.params?.slug;
1618
if (!slug) {
@@ -72,10 +74,10 @@ async function handleGet(req: Request, ctx: RouteContext) {
7274
}
7375

7476
await Promise.all(fetches);
75-
return publicJsonResponse(result, 200, {}, { fields });
77+
return publicJsonResponse(result, 200, { "Cache-Control": CACHE_CONTROL }, { fields });
7678
}
7779

78-
return publicJsonResponse(utility, 200, {}, { fields });
80+
return publicJsonResponse(utility, 200, { "Cache-Control": CACHE_CONTROL }, { fields });
7981
}
8082

8183
const handler = withRequestId(withErrorHandling(withTiming(handleGet)));
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { and, eq, isNull } from "drizzle-orm";
2+
import type { NextRequest } from "next/server";
3+
import { ApiError } from "@/lib/api/errors";
4+
import { generateRequestId, withErrorHandling, withRequestId, withTiming } from "@/lib/api/middleware";
5+
import { publicJsonResponse } from "@/lib/api/public-response";
6+
import type { RouteContext } from "@/lib/api/types";
7+
import { getDb } from "@/lib/db/client";
8+
import { balancingAuthorities, isos, rtos, utilities } from "@/lib/db/schema";
9+
10+
// ---------------------------------------------------------------------------
11+
// GET /api/v1/utilities/by-eia-id/[eiaId] — Get utility by EIA Utility ID
12+
//
13+
// Sibling route to /api/v1/utilities/[slug]. Some consumers (especially
14+
// server-to-server integrations that hold the canonical EIA ID rather than
15+
// the Texture slug) find the slug route awkward; this route lets them look
16+
// up the same record via the numeric-ish EIA ID directly.
17+
//
18+
// Identical response shape and ?include=iso,rto,ba / ?fields semantics to
19+
// the slug route.
20+
// ---------------------------------------------------------------------------
21+
22+
// Edge caching: utility records change at most a few times a year per row
23+
// (EIA Form 861 annual update, occasional admin edits). A 1 hour shared-
24+
// maxage with 24 hour SWR is generous enough for DataLoader-batched fan-out
25+
// from downstream consumers to be effectively free at steady state, while
26+
// still reflecting admin edits within the hour. Matches the slug route.
27+
const CACHE_CONTROL = "public, s-maxage=3600, stale-while-revalidate=86400";
28+
29+
async function handleGet(req: Request, ctx: RouteContext) {
30+
const eiaId = ctx.params?.eiaId;
31+
if (!eiaId) {
32+
throw new ApiError("BAD_REQUEST", "Missing eiaId parameter");
33+
}
34+
35+
const url = new URL(req.url);
36+
const include = url.searchParams.get("include");
37+
const fields = url.searchParams.get("fields");
38+
39+
const db = getDb();
40+
const [utility] = await db
41+
.select()
42+
.from(utilities)
43+
.where(and(eq(utilities.eiaId, eiaId), isNull(utilities.deletedAt)))
44+
.limit(1);
45+
46+
if (!utility) {
47+
throw new ApiError("NOT_FOUND", `Utility with EIA ID '${eiaId}' not found`);
48+
}
49+
50+
const headers = { "Cache-Control": CACHE_CONTROL };
51+
52+
if (include) {
53+
const includes = include.split(",").map((i) => i.trim());
54+
const result: Record<string, unknown> = { ...utility };
55+
56+
const fetches: Promise<void>[] = [];
57+
58+
if (includes.includes("iso") && utility.isoId) {
59+
fetches.push(
60+
db
61+
.select()
62+
.from(isos)
63+
.where(eq(isos.id, utility.isoId))
64+
.limit(1)
65+
.then(([iso]) => {
66+
result._iso = iso ?? null;
67+
})
68+
);
69+
}
70+
if (includes.includes("rto") && utility.rtoId) {
71+
fetches.push(
72+
db
73+
.select()
74+
.from(rtos)
75+
.where(eq(rtos.id, utility.rtoId))
76+
.limit(1)
77+
.then(([rto]) => {
78+
result._rto = rto ?? null;
79+
})
80+
);
81+
}
82+
if (includes.includes("ba") && utility.balancingAuthorityId) {
83+
fetches.push(
84+
db
85+
.select()
86+
.from(balancingAuthorities)
87+
.where(eq(balancingAuthorities.id, utility.balancingAuthorityId))
88+
.limit(1)
89+
.then(([ba]) => {
90+
result._ba = ba ?? null;
91+
})
92+
);
93+
}
94+
95+
await Promise.all(fetches);
96+
return publicJsonResponse(result, 200, headers, { fields });
97+
}
98+
99+
return publicJsonResponse(utility, 200, headers, { fields });
100+
}
101+
102+
const handler = withRequestId(withErrorHandling(withTiming(handleGet)));
103+
104+
export async function GET(req: NextRequest, { params }: { params: Promise<{ eiaId: string }> }) {
105+
const { eiaId } = await params;
106+
return handler(req, { params: { eiaId }, requestId: generateRequestId() });
107+
}

0 commit comments

Comments
 (0)