116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
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(),
|
||
};
|
||
}
|
||
}
|
||
}
|