# Kener API Development Instructions This document provides guidelines for creating new API endpoints in Kener. Follow these patterns to maintain consistency across all APIs. ## API Architecture Overview ### Directory Structure ``` src/routes/(api)/api/ ├── {resource}/ │ ├── +server.ts # GET (list), POST (create) │ └── [{resource}_id]/ │ ├── +server.ts # GET, PATCH, DELETE (single resource) │ └── {sub-resource}/ │ ├── +server.ts # GET (list), POST (create) │ └── [{sub_id}]/ │ └── +server.ts # GET, PATCH, DELETE (single sub-resource) ``` ### Key Files - **Types**: `src/lib/types/api.ts` - All API request/response types (snake_case) - **Middleware**: `src/hooks.server.ts` - Authentication and resource validation - **App Locals**: `src/app.d.ts` - TypeScript declarations for `event.locals` - **Repository**: `src/lib/server/db/repositories/*.ts` - Database operations - **DbImpl**: `src/lib/server/db/dbimpl.ts` - Bindings for repository methods ### Current Locals (set by middleware in `hooks.server.ts`) ```typescript interface Locals { user?: SessionUser; // Auth session monitor?: MonitorRecordTyped; // /api/monitors/:monitor_tag/* incident?: IncidentRecord; // /api/incidents/:incident_id/* maintenance?: MaintenanceRecord; // /api/maintenances/:maintenance_id/* page?: PageRecord; // /api/pages/:page_path/* } ``` ## Naming Conventions ### Use snake_case for API payloads ```typescript // Correct interface CreateMonitorRequest { monitor_tag: string; start_date_time: number; duration_seconds: number; } // Wrong interface CreateMonitorRequest { monitorTag: string; startDateTime: number; durationSeconds: number; } ``` ### Type Naming Pattern ```typescript // List response interface Get{Resource}sListResponse { {resources}: {Resource}Response[]; } // Single resource response interface Get{Resource}Response { {resource}: {Resource}DetailResponse; } // Create request/response interface Create{Resource}Request { ... } interface Create{Resource}Response { {resource}: {Resource}Response; } // Update request/response interface Update{Resource}Request { ... } interface Update{Resource}Response { {resource}: {Resource}Response; } // Delete response interface Delete{Resource}Response { message: string; } // Error responses (reuse existing) interface BadRequestResponse { error: { code: string; message: string; } } interface NotFoundResponse { error: { code: string; message: string; } } interface UnauthorizedResponse { error: { code: string; message: string; } } ``` ## Middleware Pattern ### 1. Add Route Regex Pattern in `hooks.server.ts` ```typescript const RESOURCE_ID_ROUTE_REGEX = /^\/api\/resources\/(\d+)/; function extractResourceId(pathname: string): number | null { const match = pathname.match(RESOURCE_ID_ROUTE_REGEX); return match ? parseInt(match[1], 10) : null; } ``` ### 2. Add Validation Block in `handle()` Function ```typescript // Validate resource_id exists for /api/resources/:resource_id/* routes const resourceId = extractResourceId(pathname); if (resourceId) { const resource = await db.getResourceById(resourceId); if (!resource) { const errorResponse: NotFoundResponse = { error: { code: "NOT_FOUND", message: `Resource with id '${resourceId}' not found`, }, }; return json(errorResponse, { status: 404 }); } // Store resource in locals for use in endpoints event.locals.resource = resource; } ``` ### 3. Declare in `app.d.ts` ```typescript interface Locals { // Set by hooks.server.ts for /api/resources/:resource_id/* routes resource?: import("$lib/server/types/db").ResourceRecord; } ``` ## Endpoint Implementation Pattern ### GET (List) ```typescript import { json, type RequestHandler } from "@sveltejs/kit"; import db from "$lib/server/db/db"; import type { GetResourcesListResponse, ResourceResponse } from "$lib/types/api"; function formatDateToISO(date: Date | string): string { if (date instanceof Date) return date.toISOString(); const parsed = new Date(date.replace(" ", "T") + "Z"); return parsed.toISOString(); } export const GET: RequestHandler = async ({ url }) => { // Parse query params for filtering const statusParam = url.searchParams.get("status"); const pageParam = url.searchParams.get("page"); const limitParam = url.searchParams.get("limit"); const page = pageParam ? Math.max(1, parseInt(pageParam, 10) || 1) : 1; const limit = limitParam ? Math.min(100, Math.max(1, parseInt(limitParam, 10) || 20)) : 20; // Build filter const filter: { status?: string } = {}; if (statusParam) filter.status = statusParam; // Query database const rawResources = await db.getResourcesPaginated(page, limit, filter); // Transform to response format const resources: ResourceResponse[] = rawResources.map((r) => ({ id: r.id, name: r.name, created_at: formatDateToISO(r.created_at), updated_at: formatDateToISO(r.updated_at), })); const response: GetResourcesListResponse = { resources }; return json(response); }; ``` ### POST (Create) ```typescript export const POST: RequestHandler = async ({ request }) => { let body: CreateResourceRequest; try { body = await request.json(); } catch { const errorResponse: BadRequestResponse = { error: { code: "BAD_REQUEST", message: "Invalid JSON body" }, }; return json(errorResponse, { status: 400 }); } // Validate required fields if (!body.name || typeof body.name !== "string" || body.name.trim().length === 0) { const errorResponse: BadRequestResponse = { error: { code: "BAD_REQUEST", message: "name is required and must be a non-empty string" }, }; return json(errorResponse, { status: 400 }); } // Normalize timestamps using helper const normalizedTimestamp = GetMinuteStartTimestampUTC(body.start_date_time); // Create resource const created = await db.createResource({ name: body.name.trim(), start_date_time: normalizedTimestamp, }); // Build response const resourceResponse = await buildResourceResponse(created.id); const response: CreateResourceResponse = { resource: resourceResponse }; return json(response, { status: 201 }); }; ``` ### GET (Single) - Uses Middleware ```typescript export const GET: RequestHandler = async ({ locals }) => { // Resource is validated by middleware and available in locals const resource = locals.resource!; const resourceResponse = await buildResourceResponse(resource.id); const response: GetResourceResponse = { resource: resourceResponse }; return json(response); }; ``` ### PATCH (Update) - Uses Middleware ```typescript export const PATCH: RequestHandler = async ({ locals, request }) => { const existingResource = locals.resource!; let body: UpdateResourceRequest; try { body = await request.json(); } catch { return json({ error: { code: "BAD_REQUEST", message: "Invalid JSON body" } }, { status: 400 }); } // Validate fields if provided if (body.status !== undefined && !["ACTIVE", "INACTIVE"].includes(body.status)) { return json({ error: { code: "BAD_REQUEST", message: "status must be 'ACTIVE' or 'INACTIVE'" } }, { status: 400 }); } // Build update data - only include fields present in request const updateData: Record = {}; if (body.name !== undefined) updateData.name = body.name.trim(); if (body.status !== undefined) updateData.status = body.status; // Update if there's data to update if (Object.keys(updateData).length > 0) { await db.updateResource(existingResource.id, updateData); } const resourceResponse = await buildResourceResponse(existingResource.id); const response: UpdateResourceResponse = { resource: resourceResponse }; return json(response); }; ``` ### DELETE - Uses Middleware ```typescript export const DELETE: RequestHandler = async ({ locals }) => { const resource = locals.resource!; // Delete related records first (cascade) await db.deleteResourceRelatedRecords(resource.id); // Delete the resource itself await db.deleteResource(resource.id); const response: DeleteResourceResponse = { message: `Resource with id '${resource.id}' deleted successfully`, }; return json(response); }; ``` ## Timestamp Handling ### Always normalize timestamps ```typescript import { GetMinuteStartTimestampUTC, GetNowTimestampUTC } from "$lib/server/tool"; // For user-provided timestamps - normalize to minute start const normalizedTs = GetMinuteStartTimestampUTC(body.start_date_time); // For current time (when timestamp is optional) const now = GetNowTimestampUTC(); // For optional timestamp with fallback const timestamp = body.timestamp !== undefined ? GetMinuteStartTimestampUTC(body.timestamp) : GetMinuteStartNowTimestampUTC(); ``` ## Validation Patterns ### Required Field Validation ```typescript if (body.field === undefined || body.field === null) { return json({ error: { code: "BAD_REQUEST", message: "field is required" } }, { status: 400 }); } ``` ### Type Validation ```typescript if (typeof body.count !== "number" || isNaN(body.count) || body.count <= 0) { return json({ error: { code: "BAD_REQUEST", message: "count must be a positive number" } }, { status: 400 }); } ``` ### Enum Validation ```typescript const VALID_STATUSES = ["ACTIVE", "INACTIVE"]; if (body.status && !VALID_STATUSES.includes(body.status)) { return json({ error: { code: "BAD_REQUEST", message: `status must be one of: ${VALID_STATUSES.join(", ")}` } }, { status: 400 }); } ``` ### Foreign Key Validation ```typescript if (body.monitor_tag) { const monitor = await db.getMonitorByTag(body.monitor_tag); if (!monitor) { return json({ error: { code: "BAD_REQUEST", message: `Monitor with tag '${body.monitor_tag}' not found` } }, { status: 400 }); } } ``` ### Array Validation ```typescript if (body.items !== undefined) { if (!Array.isArray(body.items)) { return json({ error: { code: "BAD_REQUEST", message: "items must be an array" } }, { status: 400 }); } for (const item of body.items) { if (!item.tag || typeof item.tag !== "string") { return json({ error: { code: "BAD_REQUEST", message: "Each item must have a valid tag" } }, { status: 400 }); } } } ``` ## Adding Repository Methods ### 1. Add Method to Repository Class ```typescript // In src/lib/server/db/repositories/{resource}.ts async getResourcesWithDetails(options: { page: number; limit: number; filter?: { status?: string }; }): Promise<{ resources: ResourceRecord[]; total: number }> { // Implementation } ``` ### 2. Declare Method Type in DbImpl ```typescript // In src/lib/server/db/dbimpl.ts - declarations section getResourcesWithDetails!: ResourceRepository["getResourcesWithDetails"]; ``` ### 3. Bind Method in DbImpl Constructor ```typescript // In src/lib/server/db/dbimpl.ts - bindResourceMethods() this.getResourcesWithDetails = this.resources.getResourcesWithDetails.bind(this.resources); ``` ## Common Imports ```typescript import { json, type RequestHandler } from "@sveltejs/kit"; import db from "$lib/server/db/db"; import type { Get{Resource}Response, Create{Resource}Request, Create{Resource}Response, Update{Resource}Request, Update{Resource}Response, Delete{Resource}Response, BadRequestResponse, NotFoundResponse, } from "$lib/types/api"; import { GetMinuteStartTimestampUTC, GetNowTimestampUTC } from "$lib/server/tool"; ``` ## Response Status Codes - `200` - GET success, PATCH success, DELETE success - `201` - POST success (resource created) - `400` - Bad Request (validation errors) - `401` - Unauthorized (no/invalid token) - `404` - Not Found (resource doesn't exist) - `500` - Internal Server Error ## Testing with cURL ```bash # List curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/resources # Create curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"name":"Test","start_date_time":1735689600}' \ http://localhost:3000/api/resources # Get single curl -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/resources/1 # Update curl -X PATCH -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"name":"Updated"}' \ http://localhost:3000/api/resources/1 # Delete curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/resources/1 ``` ## Checklist for New API 1. [ ] Define types in `src/lib/types/api.ts` 2. [ ] Add middleware validation in `src/hooks.server.ts` (if resource has ID routes) 3. [ ] Update `src/app.d.ts` with locals type 4. [ ] Create endpoint files in `src/routes/(api)/api/{resource}/` 5. [ ] Add repository methods if needed 6. [ ] Bind repository methods in DbImpl 7. [ ] Test all endpoints with cURL