From ff1fc64ca8645f1508bedfb1f9b7b2e54a2b71b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Tue, 10 Mar 2026 16:45:34 +0100 Subject: [PATCH] add server-side DIP API client with Vorgang detail fetching Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + src/server/shared/lib/dip-api.test.ts | 82 +++++++++++++++++++++++ src/server/shared/lib/dip-api.ts | 96 +++++++++++++++++++++++++++ src/server/shared/lib/env.ts | 1 + 4 files changed, 180 insertions(+) create mode 100644 src/server/shared/lib/dip-api.test.ts create mode 100644 src/server/shared/lib/dip-api.ts diff --git a/.env.example b/.env.example index 773b920..68cd663 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ PORT=3000 VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= VAPID_SUBJECT=mailto:your-email@example.com +DIP_API_KEY=GmEPb1B.bfqJLIhcGAsH9fTJevTglhFpCoZyAAAdhp diff --git a/src/server/shared/lib/dip-api.test.ts b/src/server/shared/lib/dip-api.test.ts new file mode 100644 index 0000000..5ca13a5 --- /dev/null +++ b/src/server/shared/lib/dip-api.test.ts @@ -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") + }) + }) +}) diff --git a/src/server/shared/lib/dip-api.ts b/src/server/shared/lib/dip-api.ts new file mode 100644 index 0000000..d222a82 --- /dev/null +++ b/src/server/shared/lib/dip-api.ts @@ -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 +export type VorgangDetail = z.infer + +// --- Fetch helpers --- + +async function dipFetch(url: string): Promise { + 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( + path: string, + params: Record, + schema: z.ZodType, +): Promise { + 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 { + 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 { + 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) +} diff --git a/src/server/shared/lib/env.ts b/src/server/shared/lib/env.ts index e13d2e3..624c33f 100644 --- a/src/server/shared/lib/env.ts +++ b/src/server/shared/lib/env.ts @@ -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)