Commit 2: REST-API für Geräte und Dienste (Zod-Validierung, Drizzle-Repositories)

This commit is contained in:
2026-07-19 01:53:43 +02:00
parent 3a7f423f60
commit a5b998b421
11 changed files with 418 additions and 5 deletions

View File

@@ -0,0 +1,86 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import type { Device, DeviceCreateInput, DeviceUpdateInput } from "@launchpad/shared";
import { db } from "../client.js";
import { devices } from "../schema.js";
function nowIso(): string {
return new Date().toISOString();
}
function mapRow(row: typeof devices.$inferSelect): Device {
return {
id: row.id,
hostname: row.hostname,
ip: row.ip,
mac: row.mac,
manufacturer: row.manufacturer,
model: row.model,
online: row.online,
source: row.source as Device["source"],
lastScan: row.lastScan,
};
}
export function listDevices(): Device[] {
return db.select().from(devices).all().map(mapRow);
}
export function getDevice(id: string): Device | null {
const row = db.select().from(devices).where(eq(devices.id, id)).get();
return row ? mapRow(row) : null;
}
export function createDevice(input: DeviceCreateInput): Device {
const id = randomUUID();
const timestamp = nowIso();
db.insert(devices)
.values({
id,
hostname: input.hostname,
ip: input.ip,
mac: input.mac ?? null,
manufacturer: input.manufacturer ?? null,
model: input.model ?? null,
online: input.online ?? false,
source: input.source ?? "manual",
lastScan: null,
createdAt: timestamp,
updatedAt: timestamp,
})
.run();
return getDevice(id)!;
}
/**
* Aktualisiert ein Gerät anhand von Benutzereingaben (z. B. über die Admin-UI).
* Ein separater Pfad für automatische Scan-Ergebnisse folgt in einem späteren
* Commit (siehe docs/ROADMAP.md, Commit 5 Scanner).
*/
export function updateDevice(id: string, input: DeviceUpdateInput): Device | null {
const existing = getDevice(id);
if (!existing) return null;
db.update(devices)
.set({
...(input.hostname !== undefined && { hostname: input.hostname }),
...(input.ip !== undefined && { ip: input.ip }),
...(input.mac !== undefined && { mac: input.mac }),
...(input.manufacturer !== undefined && { manufacturer: input.manufacturer }),
...(input.model !== undefined && { model: input.model }),
...(input.online !== undefined && { online: input.online }),
...(input.source !== undefined && { source: input.source }),
updatedAt: nowIso(),
})
.where(eq(devices.id, id))
.run();
return getDevice(id);
}
export function deleteDevice(id: string): boolean {
const result = db.delete(devices).where(eq(devices.id, id)).run();
return result.changes > 0;
}

View File

@@ -0,0 +1,122 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import type { Service, ServiceCreateInput, ServiceUpdateInput } from "@launchpad/shared";
import { db } from "../client.js";
import { services } from "../schema.js";
function nowIso(): string {
return new Date().toISOString();
}
function mapRow(row: typeof services.$inferSelect): Service {
return {
id: row.id,
deviceId: row.deviceId,
displayName: row.displayName,
hostname: row.hostname,
url: row.url,
https: row.https,
port: row.port,
category: row.category,
icon: row.icon,
favicon: row.favicon,
description: row.description,
favorite: row.favorite,
alias: JSON.parse(row.alias) as string[],
order: row.order,
};
}
export interface ServiceFilter {
deviceId?: string;
category?: string;
favorite?: boolean;
}
export function listServices(filter: ServiceFilter = {}): Service[] {
return db
.select()
.from(services)
.all()
.map(mapRow)
.filter((service) => {
if (filter.deviceId && service.deviceId !== filter.deviceId) return false;
if (filter.category && service.category !== filter.category) return false;
if (filter.favorite !== undefined && service.favorite !== filter.favorite) return false;
return true;
});
}
export function listServicesByDevice(deviceId: string): Service[] {
return listServices({ deviceId });
}
export function getService(id: string): Service | null {
const row = db.select().from(services).where(eq(services.id, id)).get();
return row ? mapRow(row) : null;
}
export function createService(input: ServiceCreateInput): Service {
const id = randomUUID();
const timestamp = nowIso();
db.insert(services)
.values({
id,
deviceId: input.deviceId,
displayName: input.displayName,
hostname: input.hostname,
url: input.url,
https: input.https ?? false,
port: input.port,
category: input.category ?? null,
icon: input.icon ?? null,
favicon: input.favicon ?? null,
description: input.description ?? null,
favorite: input.favorite ?? false,
alias: JSON.stringify(input.alias ?? []),
order: input.order ?? 0,
createdAt: timestamp,
updatedAt: timestamp,
})
.run();
return getService(id)!;
}
/**
* Aktualisiert einen Dienst anhand von Benutzereingaben (z. B. über die Admin-UI).
* Ein separater Pfad für automatische Scan-Ergebnisse folgt in einem späteren
* Commit (siehe docs/ROADMAP.md, Commit 5 Scanner): Scans dürfen displayName,
* category, favorite, order, alias und icon niemals überschreiben.
*/
export function updateService(id: string, input: ServiceUpdateInput): Service | null {
const existing = getService(id);
if (!existing) return null;
db.update(services)
.set({
...(input.displayName !== undefined && { displayName: input.displayName }),
...(input.hostname !== undefined && { hostname: input.hostname }),
...(input.url !== undefined && { url: input.url }),
...(input.https !== undefined && { https: input.https }),
...(input.port !== undefined && { port: input.port }),
...(input.category !== undefined && { category: input.category }),
...(input.icon !== undefined && { icon: input.icon }),
...(input.favicon !== undefined && { favicon: input.favicon }),
...(input.description !== undefined && { description: input.description }),
...(input.favorite !== undefined && { favorite: input.favorite }),
...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }),
...(input.order !== undefined && { order: input.order }),
updatedAt: nowIso(),
})
.where(eq(services.id, id))
.run();
return getService(id);
}
export function deleteService(id: string): boolean {
const result = db.delete(services).where(eq(services.id, id)).run();
return result.changes > 0;
}

View File

@@ -2,6 +2,8 @@ import Fastify from "fastify";
import cors from "@fastify/cors";
import { ensureSchema } from "./db/client.js";
import { healthRoutes } from "./routes/health.js";
import { deviceRoutes } from "./routes/devices.js";
import { serviceRoutes } from "./routes/services.js";
const PORT = Number(process.env.PORT ?? 3001);
const HOST = process.env.HOST ?? "0.0.0.0";
@@ -24,6 +26,8 @@ async function main() {
ensureSchema();
await app.register(healthRoutes);
await app.register(deviceRoutes);
await app.register(serviceRoutes);
app.get("/", async () => {
return { name: "LaunchPad API", status: "running" };

View File

@@ -0,0 +1,53 @@
import type { FastifyInstance } from "fastify";
import { DeviceCreateSchema, DeviceUpdateSchema } from "@launchpad/shared";
import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js";
export async function deviceRoutes(app: FastifyInstance): Promise<void> {
app.get("/api/devices", async () => {
return deviceRepo.listDevices().map((device) => ({
...device,
services: serviceRepo.listServicesByDevice(device.id),
}));
});
app.get("/api/devices/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const device = deviceRepo.getDevice(id);
if (!device) {
return reply.code(404).send({ error: "Gerät nicht gefunden" });
}
return { ...device, services: serviceRepo.listServicesByDevice(id) };
});
app.post("/api/devices", async (request, reply) => {
const parsed = DeviceCreateSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
}
const device = deviceRepo.createDevice(parsed.data);
return reply.code(201).send(device);
});
app.patch("/api/devices/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const parsed = DeviceUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
}
const device = deviceRepo.updateDevice(id, parsed.data);
if (!device) {
return reply.code(404).send({ error: "Gerät nicht gefunden" });
}
return device;
});
app.delete("/api/devices/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const deleted = deviceRepo.deleteDevice(id);
if (!deleted) {
return reply.code(404).send({ error: "Gerät nicht gefunden" });
}
return reply.code(204).send();
});
}

View File

@@ -0,0 +1,67 @@
import type { FastifyInstance } from "fastify";
import { ServiceCreateSchema, ServiceUpdateSchema } from "@launchpad/shared";
import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js";
interface ServiceListQuery {
deviceId?: string;
category?: string;
favorite?: string;
}
export async function serviceRoutes(app: FastifyInstance): Promise<void> {
app.get("/api/services", async (request) => {
const query = request.query as ServiceListQuery;
return serviceRepo.listServices({
deviceId: query.deviceId,
category: query.category,
favorite: query.favorite === undefined ? undefined : query.favorite === "true",
});
});
app.get("/api/services/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const service = serviceRepo.getService(id);
if (!service) {
return reply.code(404).send({ error: "Dienst nicht gefunden" });
}
return service;
});
app.post("/api/services", async (request, reply) => {
const parsed = ServiceCreateSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
}
const device = deviceRepo.getDevice(parsed.data.deviceId);
if (!device) {
return reply.code(400).send({ error: `Gerät ${parsed.data.deviceId} existiert nicht` });
}
const service = serviceRepo.createService(parsed.data);
return reply.code(201).send(service);
});
app.patch("/api/services/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const parsed = ServiceUpdateSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues });
}
const service = serviceRepo.updateService(id, parsed.data);
if (!service) {
return reply.code(404).send({ error: "Dienst nicht gefunden" });
}
return service;
});
app.delete("/api/services/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const deleted = serviceRepo.deleteService(id);
if (!deleted) {
return reply.code(404).send({ error: "Dienst nicht gefunden" });
}
return reply.code(204).send();
});
}