Files
kener/.github/copilot-api-instructions.md
T

13 KiB

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)

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

// 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

// 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

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

// 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

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)

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)

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

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

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<string, unknown> = {};
  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

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

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

if (body.field === undefined || body.field === null) {
  return json({ error: { code: "BAD_REQUEST", message: "field is required" } }, { status: 400 });
}

Type Validation

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

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

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

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

// 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

// In src/lib/server/db/dbimpl.ts - declarations section
getResourcesWithDetails!: ResourceRepository["getResourcesWithDetails"];

3. Bind Method in DbImpl Constructor

// In src/lib/server/db/dbimpl.ts - bindResourceMethods()
this.getResourcesWithDetails = this.resources.getResourcesWithDetails.bind(this.resources);

Common Imports

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

# 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