add server-side DIP API client with Vorgang detail fetching
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,3 +3,4 @@ PORT=3000
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
VAPID_SUBJECT=mailto:your-email@example.com
|
||||
DIP_API_KEY=GmEPb1B.bfqJLIhcGAsH9fTJevTglhFpCoZyAAAdhp
|
||||
|
||||
82
src/server/shared/lib/dip-api.test.ts
Normal file
82
src/server/shared/lib/dip-api.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
vi.stubGlobal("fetch", mockFetch)
|
||||
|
||||
// mock env to avoid needing real env vars in test
|
||||
vi.mock("./env", () => ({
|
||||
env: {
|
||||
DIP_API_KEY: "test-api-key",
|
||||
},
|
||||
}))
|
||||
|
||||
// must import after stubbing
|
||||
const { fetchVorgangDetail, fetchUpcomingVorgaenge } = await import("./dip-api")
|
||||
|
||||
describe("server dip-api", () => {
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
describe("fetchVorgangDetail", () => {
|
||||
it("fetches and parses a Vorgang by ID", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
titel: "Entwurf eines Gesetzes",
|
||||
beratungsstand: "Beschlussempfehlung liegt vor",
|
||||
abstract: "Zusammenfassung des Gesetzentwurfs",
|
||||
sachgebiet: ["Innere Sicherheit"],
|
||||
datum: "2026-03-01",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const result = await fetchVorgangDetail(12345)
|
||||
expect(result).toEqual({
|
||||
titel: "Entwurf eines Gesetzes",
|
||||
beratungsstand: "Beschlussempfehlung liegt vor",
|
||||
abstract: "Zusammenfassung des Gesetzentwurfs",
|
||||
sachgebiet: ["Innere Sicherheit"],
|
||||
datum: "2026-03-01",
|
||||
})
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledOnce()
|
||||
const url = mockFetch.mock.calls[0][0]
|
||||
expect(url).toContain("/vorgang/12345")
|
||||
})
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response("Not Found", { status: 404 }),
|
||||
)
|
||||
|
||||
await expect(fetchVorgangDetail(99999)).rejects.toThrow("DIP API 404")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchUpcomingVorgaenge", () => {
|
||||
it("fetches and parses upcoming Vorgänge list", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
documents: [
|
||||
{
|
||||
id: 1,
|
||||
titel: "Gesetz A",
|
||||
beratungsstand: "Beschlussempfehlung liegt vor",
|
||||
datum: "2026-03-01",
|
||||
vorgangstyp: "Gesetzgebung",
|
||||
sachgebiet: ["Wirtschaft"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const result = await fetchUpcomingVorgaenge()
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].titel).toBe("Gesetz A")
|
||||
})
|
||||
})
|
||||
})
|
||||
96
src/server/shared/lib/dip-api.ts
Normal file
96
src/server/shared/lib/dip-api.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod"
|
||||
import { env } from "./env"
|
||||
|
||||
const DIP_API_BASE = "https://search.dip.bundestag.de/api/v1"
|
||||
const DIP_API_TIMEOUT_MS = 30_000
|
||||
const BUNDESTAG_WAHLPERIODE = 21
|
||||
|
||||
// --- Zod Schemas ---
|
||||
|
||||
export const vorgangSchema = z.object({
|
||||
id: z.number(),
|
||||
titel: z.string(),
|
||||
beratungsstand: z.string().nullable().optional(),
|
||||
datum: z.string().nullable().optional(),
|
||||
vorgangstyp: z.string().nullable().optional(),
|
||||
sachgebiet: z.array(z.string()).nullable().optional(),
|
||||
})
|
||||
|
||||
export const vorgangDetailSchema = z.object({
|
||||
titel: z.string(),
|
||||
beratungsstand: z.string().nullable().optional(),
|
||||
abstract: z.string().nullable().optional(),
|
||||
sachgebiet: z.array(z.string()).nullable().optional(),
|
||||
datum: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type Vorgang = z.infer<typeof vorgangSchema>
|
||||
export type VorgangDetail = z.infer<typeof vorgangDetailSchema>
|
||||
|
||||
// --- Fetch helpers ---
|
||||
|
||||
async function dipFetch(url: string): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), DIP_API_TIMEOUT_MS)
|
||||
try {
|
||||
return await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
Authorization: `ApiKey ${env.DIP_API_KEY}`,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function dipRequest<T>(
|
||||
path: string,
|
||||
params: Record<string, string>,
|
||||
schema: z.ZodType<T>,
|
||||
): Promise<T[]> {
|
||||
const url = new URL(`${DIP_API_BASE}/${path}`)
|
||||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
|
||||
|
||||
const res = await dipFetch(url.toString())
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "")
|
||||
throw new Error(`DIP API ${res.status} for ${url}: ${body}`)
|
||||
}
|
||||
|
||||
const json = (await res.json()) as { documents: unknown[] }
|
||||
return z.array(schema).parse(json.documents)
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
export function fetchUpcomingVorgaenge(): Promise<Vorgang[]> {
|
||||
return dipRequest(
|
||||
"vorgang",
|
||||
{
|
||||
"f.beratungsstand": "Beschlussempfehlung liegt vor",
|
||||
"f.vorgangstyp": "Gesetzgebung",
|
||||
"f.wahlperiode": String(BUNDESTAG_WAHLPERIODE),
|
||||
format: "json",
|
||||
},
|
||||
vorgangSchema,
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchVorgangDetail(
|
||||
vorgangsId: number,
|
||||
): Promise<VorgangDetail> {
|
||||
const url = `${DIP_API_BASE}/vorgang/${vorgangsId}?format=json`
|
||||
const res = await dipFetch(url)
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "")
|
||||
throw new Error(`DIP API ${res.status} for ${url}: ${body}`)
|
||||
}
|
||||
|
||||
const json = await res.json()
|
||||
return vorgangDetailSchema.parse(json)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ const envSchema = z.object({
|
||||
VAPID_PUBLIC_KEY: z.string().min(1),
|
||||
VAPID_PRIVATE_KEY: z.string().min(1),
|
||||
VAPID_SUBJECT: z.string().startsWith("mailto:"),
|
||||
DIP_API_KEY: z.string().min(1),
|
||||
})
|
||||
|
||||
export const env = envSchema.parse(process.env)
|
||||
|
||||
Reference in New Issue
Block a user