Files
Cibello-app/packages/connectors/src/livsmedelsverket.ts
T
2026-08-05 19:21:11 +07:00

116 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
import type { Connector, ConnectorContext, ConnectorHealth, ImportResult } from "./types.js";
/**
* Livsmedelsverkets livsmedelsdatabas öppna data (laglig kärnkälla för
* näringsvärden, spec §15/§26/§61.11). Detta är den avsedda produktionskällan
* som ersätter seed-estimaten i canonical_ingredients.
*
* API-dokumentation: https://dataportal.livsmedelsverket.se
* (endpoint konfigureras via LIVSMEDELSVERKET_API_URL verifiera aktuell
* version innan produktionsimport; formatet nedan valideras med Zod).
*/
const livsmedelSchema = z.object({
nummer: z.number().or(z.string()),
namn: z.string(),
naringsvarden: z
.array(
z.object({
namn: z.string(),
varde: z.number().nullable(),
enhet: z.string().nullable(),
}),
)
.optional(),
});
export interface LivsmedelsverketItem {
externalId: string;
nameSv: string;
nutrients: Array<{ name: string; value: number | null; unit: string | null }>;
}
export class LivsmedelsverketConnector implements Connector<LivsmedelsverketItem> {
readonly id = "livsmedelsverket" as const;
readonly nameSv = "Livsmedelsverkets livsmedelsdatabas";
readonly legalBasis = "open_data" as const;
readonly attributionSv = "Näringsdata: Livsmedelsverkets livsmedelsdatabas";
constructor(
private readonly baseUrl: string = process.env.LIVSMEDELSVERKET_API_URL ??
"https://dataportal.livsmedelsverket.se/livsmedel/api/v1",
private readonly fetchImpl: typeof fetch = fetch,
) {}
async connect(): Promise<void> {
/* Öppna data ingen anslutning krävs. */
}
async authenticate(): Promise<boolean> {
return true; // Öppet API utan nyckel.
}
async importData(
_ctx: ConnectorContext,
params: { offset?: number; limit?: number } = {},
): Promise<ImportResult<LivsmedelsverketItem>> {
const limit = params.limit ?? 100;
const offset = params.offset ?? 0;
const warnings: string[] = [];
const url = `${this.baseUrl}/livsmedel?offset=${offset}&limit=${limit}`;
const res = await this.fetchImpl(url, { signal: AbortSignal.timeout(20_000) });
if (!res.ok) {
throw new Error(`Livsmedelsverket svarade ${res.status} kontrollera API-version/URL.`);
}
const json: unknown = await res.json();
const rawItems: unknown[] = Array.isArray(json)
? json
: typeof json === "object" &&
json !== null &&
Array.isArray((json as { livsmedel?: unknown[] }).livsmedel)
? (json as { livsmedel: unknown[] }).livsmedel
: [];
const items: LivsmedelsverketItem[] = [];
for (const raw of rawItems) {
const parsed = livsmedelSchema.safeParse(raw);
if (!parsed.success) {
warnings.push("Rad kunde inte tolkas och hoppades över.");
continue;
}
items.push({
externalId: String(parsed.data.nummer),
nameSv: parsed.data.namn,
nutrients: (parsed.data.naringsvarden ?? []).map((n) => ({
name: n.namn,
value: n.varde,
unit: n.enhet,
})),
});
}
return { items, source: this.id, importedAt: new Date().toISOString(), warnings };
}
async sync(ctx: ConnectorContext): Promise<ImportResult<LivsmedelsverketItem>> {
return this.importData(ctx);
}
async disconnect(): Promise<void> {
/* noop */
}
async healthCheck(): Promise<ConnectorHealth> {
try {
const res = await this.fetchImpl(`${this.baseUrl}/livsmedel?offset=0&limit=1`, {
signal: AbortSignal.timeout(8_000),
});
return { ok: res.ok, detail: `status ${res.status}`, checkedAt: new Date().toISOString() };
} catch (err) {
return {
ok: false,
detail: err instanceof Error ? err.message : String(err),
checkedAt: new Date().toISOString(),
};
}
}
}