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:
2026-03-10 16:45:34 +01:00
parent 06745c240e
commit ff1fc64ca8
4 changed files with 180 additions and 0 deletions

View File

@@ -3,3 +3,4 @@ PORT=3000
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:your-email@example.com
DIP_API_KEY=GmEPb1B.bfqJLIhcGAsH9fTJevTglhFpCoZyAAAdhp

View 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")
})
})
})

View 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)
}

View File

@@ -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)