feat(import): lego-sets ueber die rebrickable-api uebernehmen
Auf "Neues Set" reicht die Setnummer von der Packung: Name, Jahr, Teile, Minifiguren und Thema kommen aus der API, das Bild wird lokal gespeichert und nicht nur verlinkt. Hersteller und Thema werden bei Bedarf angelegt, ein Oberthema inklusive. Alle Aufrufe laufen serverseitig; der Browser spricht nie mit rebrickable.com. Ohne REBRICKABLE_API_KEY bleibt der Import sichtbar, aber abgeschaltet, mit dem Hinweis, was zu tun ist - von Hand anlegen geht unveraendert. Rebrickable liefert keine Anleitungsdateien. Statt das zu verschleiern, legt der Import einen externen Link auf die LEGO-Serviceseite zur Setnummer an. Nebenbei: src/env.ts behandelt leere Werte jetzt wie nicht gesetzt - ein "BASE_URL=" in der .env fuehrte sonst zum Start-Abbruch mit "Invalid URL" statt zum Standardwert. 14 neue Unit-Tests decken Antwortverarbeitung, Setnummern-Varianten und alle Fehlerfaelle der API ab (404, 401, 429, Netzwerk). Refs Phase 2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import 'server-only';
|
||||
import { env } from '@/env';
|
||||
import { displaySetNumber, normalizeSetNumber } from './set-number';
|
||||
|
||||
export { displaySetNumber, legoInstructionsUrl, normalizeSetNumber } from './set-number';
|
||||
|
||||
/**
|
||||
* Rebrickable lookup for Lego sets.
|
||||
*
|
||||
* Entirely optional: without REBRICKABLE_API_KEY the import stays switched
|
||||
* off and everything keeps working by hand. Every request runs here on the
|
||||
* server - the browser never talks to rebrickable.com.
|
||||
*/
|
||||
|
||||
const API_BASE = 'https://rebrickable.com/api/v3/lego';
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
export function isRebrickableEnabled(): boolean {
|
||||
return Boolean(env.REBRICKABLE_API_KEY);
|
||||
}
|
||||
|
||||
export class RebrickableError extends Error {}
|
||||
|
||||
export type RebrickableSet = {
|
||||
setNumber: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
pieceCount: number | null;
|
||||
themeId: number | null;
|
||||
imageUrl: string | null;
|
||||
externalUrl: string | null;
|
||||
};
|
||||
|
||||
export type RebrickableTheme = {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
};
|
||||
|
||||
async function request<T>(path: string): Promise<T | null> {
|
||||
const key = env.REBRICKABLE_API_KEY;
|
||||
if (!key) throw new RebrickableError('Für den Import fehlt der Rebrickable-API-Key.');
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`, {
|
||||
headers: {
|
||||
Authorization: `key ${key}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
// The catalog is not a moving target; a day of caching is plenty.
|
||||
next: { revalidate: 86_400 },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[rebrickable] Anfrage fehlgeschlagen', path, error);
|
||||
throw new RebrickableError('Rebrickable ist gerade nicht erreichbar.');
|
||||
}
|
||||
|
||||
if (response.status === 404) return null;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new RebrickableError('Der Rebrickable-API-Key wird abgelehnt. Bitte prüf ihn.');
|
||||
}
|
||||
if (response.status === 429) {
|
||||
throw new RebrickableError('Rebrickable drosselt gerade. Bitte in einer Minute noch einmal.');
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new RebrickableError(`Rebrickable antwortet mit Status ${response.status}.`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
type SetResponse = {
|
||||
set_num: string;
|
||||
name: string;
|
||||
year: number | null;
|
||||
theme_id: number | null;
|
||||
num_parts: number | null;
|
||||
set_img_url: string | null;
|
||||
set_url: string | null;
|
||||
};
|
||||
|
||||
export async function fetchSet(setNumber: string): Promise<RebrickableSet | null> {
|
||||
const data = await request<SetResponse>(`/sets/${encodeURIComponent(normalizeSetNumber(setNumber))}/`);
|
||||
if (!data) return null;
|
||||
|
||||
return {
|
||||
setNumber: displaySetNumber(data.set_num),
|
||||
name: data.name,
|
||||
year: data.year ?? null,
|
||||
pieceCount: data.num_parts ?? null,
|
||||
themeId: data.theme_id ?? null,
|
||||
imageUrl: data.set_img_url ?? null,
|
||||
externalUrl: data.set_url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
type ThemeResponse = { id: number; name: string; parent_id: number | null };
|
||||
|
||||
export async function fetchTheme(themeId: number): Promise<RebrickableTheme | null> {
|
||||
const data = await request<ThemeResponse>(`/themes/${themeId}/`);
|
||||
if (!data) return null;
|
||||
return { id: data.id, name: data.name, parentId: data.parent_id ?? null };
|
||||
}
|
||||
|
||||
type MinifigsResponse = { results: Array<{ quantity: number | null }> };
|
||||
|
||||
/** Number of minifigures, or null when the lookup does not work out. */
|
||||
export async function fetchMinifigCount(setNumber: string): Promise<number | null> {
|
||||
try {
|
||||
const data = await request<MinifigsResponse>(
|
||||
`/sets/${encodeURIComponent(normalizeSetNumber(setNumber))}/minifigs/`,
|
||||
);
|
||||
if (!data) return null;
|
||||
return data.results.reduce((total, row) => total + (row.quantity ?? 0), 0);
|
||||
} catch {
|
||||
// A missing minifig list must not fail the whole import.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the set image so it keeps working when Rebrickable changes or
|
||||
* removes it. Returns null instead of throwing: an import without a picture
|
||||
* is still a good import.
|
||||
*/
|
||||
export async function fetchImageBytes(url: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||||
if (!response.ok) return null;
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
return buffer.byteLength > 0 ? buffer : null;
|
||||
} catch (error) {
|
||||
console.error('[rebrickable] Bild konnte nicht geladen werden', url, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user