From 5e3726de394abf64a93bad1d5d03fed623e8ebf4 Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Tue, 3 Mar 2026 14:49:12 +0000 Subject: [PATCH] updated openapi --- README.md | 7 +- src/index.ts | 202 ++---------- src/openapi.ts | 598 +++------------------------------- src/routes.ts | 740 +++++++++++++++++++++++++++++++++++++++++++ tests/api.test.ts | 89 +++++- tests/routes.test.ts | 41 +++ 6 files changed, 937 insertions(+), 740 deletions(-) create mode 100644 src/routes.ts create mode 100644 tests/routes.test.ts diff --git a/README.md b/README.md index 20604d0..65cee43 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This folder contains a standalone mock API for a multi-organization task manager - task, project, user, and org summary endpoints - write endpoints for creating tasks and updating task state - a small test suite covering seeded reads and task mutations -- OpenAPI 3.1 document endpoints for import tooling +- OpenAPI 3.0.3 document endpoints generated from the route registry - standalone Docker and Compose files for running the mock API by itself ## Domain Model @@ -140,8 +140,9 @@ docker compose up --build ## Files That Matter -- `src/index.ts`: HTTP routes and server startup -- `src/openapi.ts`: importable OpenAPI 3.1 document +- `src/index.ts`: server startup and route dispatch +- `src/routes.ts`: shared route registry, handlers, and request contracts +- `src/openapi.ts`: OpenAPI document generated from the route registry - `src/database.ts`: schema creation and seed data - `src/repository.ts`: query and mutation helpers - `scripts/seed.ts`: resets and reseeds the SQLite file diff --git a/src/index.ts b/src/index.ts index 5850ca1..d16ef60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,112 +2,7 @@ import type { Database } from "bun:sqlite"; import { getConfig } from "./config"; import { ensureSeededDatabase } from "./database"; import { openApiDocument } from "./openapi"; -import { - createTask, - getOrganizationSummary, - getTaskDetail, - listOrganizations, - listProjects, - listTasks, - listUsers, - updateTask, -} from "./repository"; - -function json(body: unknown, init: ResponseInit = {}) { - const headers = new Headers(init.headers); - headers.set("content-type", "application/json; charset=utf-8"); - headers.set("access-control-allow-origin", "*"); - headers.set("access-control-allow-methods", "GET,POST,PATCH,OPTIONS"); - headers.set("access-control-allow-headers", "content-type"); - return new Response(JSON.stringify(body, null, 2), { ...init, headers }); -} - -function notFound(message: string) { - return json({ error: message }, { status: 404 }); -} - -function badRequest(message: string) { - return json({ error: message }, { status: 400 }); -} - -async function readJson(request: Request) { - try { - return await request.json(); - } catch { - return null; - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function validateCreateTask(body: unknown) { - if (!isRecord(body)) { - return { error: "Request body must be a JSON object." }; - } - - if (typeof body.orgId !== "string" || body.orgId.trim() === "") { - return { error: "orgId is required." }; - } - if (typeof body.projectId !== "string" || body.projectId.trim() === "") { - return { error: "projectId is required." }; - } - if (typeof body.title !== "string" || body.title.trim() === "") { - return { error: "title is required." }; - } - - return { - value: { - orgId: body.orgId.trim(), - projectId: body.projectId.trim(), - assigneeUserId: - typeof body.assigneeUserId === "string" ? body.assigneeUserId.trim() : null, - title: body.title.trim(), - description: - typeof body.description === "string" ? body.description.trim() : undefined, - status: typeof body.status === "string" ? body.status.trim() : undefined, - priority: typeof body.priority === "string" ? body.priority.trim() : undefined, - storyPoints: - typeof body.storyPoints === "number" ? Math.trunc(body.storyPoints) : undefined, - dueDate: typeof body.dueDate === "string" ? body.dueDate : null, - }, - }; -} - -function validateUpdateTask(body: unknown) { - if (!isRecord(body)) { - return { error: "Request body must be a JSON object." }; - } - - return { - value: { - title: typeof body.title === "string" ? body.title.trim() : undefined, - description: - typeof body.description === "string" ? body.description.trim() : undefined, - status: typeof body.status === "string" ? body.status.trim() : undefined, - priority: typeof body.priority === "string" ? body.priority.trim() : undefined, - assigneeUserId: - typeof body.assigneeUserId === "string" - ? body.assigneeUserId.trim() - : body.assigneeUserId === null - ? null - : undefined, - storyPoints: - typeof body.storyPoints === "number" - ? Math.trunc(body.storyPoints) - : body.storyPoints === null - ? null - : undefined, - dueDate: - typeof body.dueDate === "string" - ? body.dueDate - : body.dueDate === null - ? null - : undefined, - }, - }; -} +import { badRequest, json, matchRoute, notFound, readJson } from "./routes"; export function createServer(db: Database) { return { @@ -118,92 +13,39 @@ export function createServer(db: Database) { } const url = new URL(request.url); - const parts = url.pathname.split("/").filter(Boolean); + const matched = matchRoute(request.method, url.pathname); - if (request.method === "GET" && url.pathname === "/health") { - return json({ status: "ok" }); + if (!matched) { + return notFound("Route not found."); } - if ( - request.method === "GET" && - (url.pathname === "/openapi.json" || - url.pathname === "/swagger.json" || - url.pathname === "/api-docs") - ) { + const { route, params } = matched; + + if (route.servesOpenApiDocument) { return json(openApiDocument); } - if (request.method === "GET" && url.pathname === "/orgs") { - return json({ organizations: listOrganizations(db) }); - } - - if (request.method === "GET" && url.pathname === "/users") { - return json({ users: listUsers(db, url.searchParams.get("orgId")) }); - } - - if (request.method === "GET" && url.pathname === "/projects") { - return json({ projects: listProjects(db, url.searchParams.get("orgId")) }); - } - - if (request.method === "GET" && url.pathname === "/tasks") { - return json({ - tasks: listTasks(db, { - orgId: url.searchParams.get("orgId"), - projectId: url.searchParams.get("projectId"), - status: url.searchParams.get("status"), - priority: url.searchParams.get("priority"), - assigneeUserId: url.searchParams.get("assigneeUserId"), - }), - }); - } - - if (request.method === "POST" && url.pathname === "/tasks") { - const payload = validateCreateTask(await readJson(request)); - if ("error" in payload) { - return badRequest(payload.error); + let body: unknown = undefined; + if (route.parseBody) { + const parsed = route.parseBody(await readJson(request)); + if ("error" in parsed) { + return badRequest(parsed.error); } - const task = createTask(db, payload.value); - return json({ task }, { status: 201 }); + body = parsed.value; } - if (parts[0] === "orgs" && parts.length === 2 && request.method === "GET") { - const summary = getOrganizationSummary(db, parts[1]!); - return summary ? json({ organization: summary }) : notFound("Organization not found."); + if (!route.handler) { + return notFound("Route not found."); } - if (parts[0] === "orgs" && parts[2] === "projects" && request.method === "GET") { - return json({ projects: listProjects(db, parts[1]!) }); - } - - if (parts[0] === "orgs" && parts[2] === "tasks" && request.method === "GET") { - return json({ - tasks: listTasks(db, { - orgId: parts[1]!, - projectId: url.searchParams.get("projectId"), - status: url.searchParams.get("status"), - priority: url.searchParams.get("priority"), - assigneeUserId: url.searchParams.get("assigneeUserId"), - }), - }); - } - - if (parts[0] === "tasks" && parts.length === 2 && request.method === "GET") { - const task = getTaskDetail(db, parts[1]!); - return task ? json({ task }) : notFound("Task not found."); - } - - if (parts[0] === "tasks" && parts.length === 2 && request.method === "PATCH") { - const payload = validateUpdateTask(await readJson(request)); - if ("error" in payload) { - return badRequest(payload.error); - } - - const task = updateTask(db, parts[1]!, payload.value); - return task ? json({ task }) : notFound("Task not found."); - } - - return notFound("Route not found."); + return route.handler({ + db, + request, + url, + params, + body, + }); }, }; } diff --git a/src/openapi.ts b/src/openapi.ts index 0bb8fd9..217e7dc 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -1,23 +1,44 @@ -const taskProperties = { - id: { type: "string" }, - orgId: { type: "string" }, - projectId: { type: "string" }, - assigneeUserId: { type: ["string", "null"] }, - title: { type: "string" }, - description: { type: "string" }, - status: { type: "string", enum: ["todo", "in_progress", "in_review", "blocked", "done"] }, - priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, - storyPoints: { type: ["integer", "null"] }, - dueDate: { type: ["string", "null"], format: "date" }, - createdAt: { type: "string", format: "date-time" }, - updatedAt: { type: "string", format: "date-time" }, - projectName: { type: "string" }, - projectKey: { type: "string" }, - assigneeName: { type: ["string", "null"] }, -} as const; +import { components, routes } from "./routes"; + +function buildPaths() { + const paths: Record> = {}; + + for (const route of routes) { + if (!paths[route.path]) { + paths[route.path] = {}; + } + + paths[route.path]![route.method.toLowerCase()] = { + operationId: route.operationId, + tags: route.tags, + summary: route.summary, + ...(route.description ? { description: route.description } : {}), + ...(route.parameters ? { parameters: route.parameters } : {}), + ...(route.requestBody ? { requestBody: route.requestBody } : {}), + responses: route.responses, + }; + } + + return paths; +} + +function buildTags() { + const seen = new Set(); + + return routes.flatMap((route) => + route.tags.flatMap((tag) => { + if (seen.has(tag)) { + return []; + } + + seen.add(tag); + return [{ name: tag }]; + }), + ); +} export const openApiDocument = { - openapi: "3.1.0", + openapi: "3.0.3", info: { title: "POC Mock Task API", version: "1.0.0", @@ -30,542 +51,7 @@ export const openApiDocument = { description: "Local development server", }, ], - tags: [ - { name: "Health" }, - { name: "Organizations" }, - { name: "Users" }, - { name: "Projects" }, - { name: "Tasks" }, - ], - paths: { - "/health": { - get: { - tags: ["Health"], - summary: "Check service health", - responses: { - "200": { - description: "Service is healthy", - content: { - "application/json": { - schema: { - type: "object", - required: ["status"], - properties: { - status: { type: "string", enum: ["ok"] }, - }, - }, - }, - }, - }, - }, - }, - }, - "/orgs": { - get: { - tags: ["Organizations"], - summary: "List organizations", - responses: { - "200": { - description: "Organizations with summary counts", - content: { - "application/json": { - schema: { - type: "object", - required: ["organizations"], - properties: { - organizations: { - type: "array", - items: { $ref: "#/components/schemas/OrganizationSummaryListItem" }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - "/orgs/{orgId}": { - get: { - tags: ["Organizations"], - summary: "Get organization detail", - parameters: [ - { - name: "orgId", - in: "path", - required: true, - schema: { type: "string" }, - }, - ], - responses: { - "200": { - description: "Organization detail and task status breakdown", - content: { - "application/json": { - schema: { - type: "object", - required: ["organization"], - properties: { - organization: { $ref: "#/components/schemas/OrganizationDetail" }, - }, - }, - }, - }, - }, - "404": { - description: "Organization not found", - }, - }, - }, - }, - "/orgs/{orgId}/projects": { - get: { - tags: ["Projects"], - summary: "List projects for an organization", - parameters: [ - { - name: "orgId", - in: "path", - required: true, - schema: { type: "string" }, - }, - ], - responses: { - "200": { - description: "Projects for the org", - content: { - "application/json": { - schema: { - type: "object", - required: ["projects"], - properties: { - projects: { - type: "array", - items: { $ref: "#/components/schemas/Project" }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - "/orgs/{orgId}/tasks": { - get: { - tags: ["Tasks"], - summary: "List tasks for an organization", - parameters: [ - { - name: "orgId", - in: "path", - required: true, - schema: { type: "string" }, - }, - { - name: "projectId", - in: "query", - schema: { type: "string" }, - }, - { - name: "status", - in: "query", - schema: { type: "string" }, - }, - { - name: "priority", - in: "query", - schema: { type: "string" }, - }, - { - name: "assigneeUserId", - in: "query", - schema: { type: "string" }, - }, - ], - responses: { - "200": { - description: "Filtered org task list", - content: { - "application/json": { - schema: { - type: "object", - required: ["tasks"], - properties: { - tasks: { - type: "array", - items: { $ref: "#/components/schemas/TaskListItem" }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - "/users": { - get: { - tags: ["Users"], - summary: "List users", - parameters: [ - { - name: "orgId", - in: "query", - schema: { type: "string" }, - }, - ], - responses: { - "200": { - description: "User list", - content: { - "application/json": { - schema: { - type: "object", - required: ["users"], - properties: { - users: { - type: "array", - items: { $ref: "#/components/schemas/User" }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - "/projects": { - get: { - tags: ["Projects"], - summary: "List projects", - parameters: [ - { - name: "orgId", - in: "query", - schema: { type: "string" }, - }, - ], - responses: { - "200": { - description: "Project list", - content: { - "application/json": { - schema: { - type: "object", - required: ["projects"], - properties: { - projects: { - type: "array", - items: { $ref: "#/components/schemas/Project" }, - }, - }, - }, - }, - }, - }, - }, - }, - }, - "/tasks": { - get: { - tags: ["Tasks"], - summary: "List tasks", - parameters: [ - { name: "orgId", in: "query", schema: { type: "string" } }, - { name: "projectId", in: "query", schema: { type: "string" } }, - { name: "status", in: "query", schema: { type: "string" } }, - { name: "priority", in: "query", schema: { type: "string" } }, - { name: "assigneeUserId", in: "query", schema: { type: "string" } }, - ], - responses: { - "200": { - description: "Filtered task list", - content: { - "application/json": { - schema: { - type: "object", - required: ["tasks"], - properties: { - tasks: { - type: "array", - items: { $ref: "#/components/schemas/TaskListItem" }, - }, - }, - }, - }, - }, - }, - }, - }, - post: { - tags: ["Tasks"], - summary: "Create a task", - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/CreateTaskRequest" }, - }, - }, - }, - responses: { - "201": { - description: "Created task detail", - content: { - "application/json": { - schema: { - type: "object", - required: ["task"], - properties: { - task: { $ref: "#/components/schemas/TaskDetail" }, - }, - }, - }, - }, - }, - "400": { - description: "Invalid payload", - }, - }, - }, - }, - "/tasks/{taskId}": { - get: { - tags: ["Tasks"], - summary: "Get task detail", - parameters: [ - { - name: "taskId", - in: "path", - required: true, - schema: { type: "string" }, - }, - ], - responses: { - "200": { - description: "Task detail with labels and comments", - content: { - "application/json": { - schema: { - type: "object", - required: ["task"], - properties: { - task: { $ref: "#/components/schemas/TaskDetail" }, - }, - }, - }, - }, - }, - "404": { - description: "Task not found", - }, - }, - }, - patch: { - tags: ["Tasks"], - summary: "Update a task", - parameters: [ - { - name: "taskId", - in: "path", - required: true, - schema: { type: "string" }, - }, - ], - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/UpdateTaskRequest" }, - }, - }, - }, - responses: { - "200": { - description: "Updated task detail", - content: { - "application/json": { - schema: { - type: "object", - required: ["task"], - properties: { - task: { $ref: "#/components/schemas/TaskDetail" }, - }, - }, - }, - }, - }, - "404": { - description: "Task not found", - }, - }, - }, - }, - "/openapi.json": { - get: { - tags: ["Health"], - summary: "Get OpenAPI document", - responses: { - "200": { - description: "OpenAPI specification", - }, - }, - }, - }, - "/swagger.json": { - get: { - tags: ["Health"], - summary: "Get OpenAPI document via swagger alias", - responses: { - "200": { - description: "OpenAPI specification", - }, - }, - }, - }, - "/api-docs": { - get: { - tags: ["Health"], - summary: "Get OpenAPI document via api-docs alias", - responses: { - "200": { - description: "OpenAPI specification", - }, - }, - }, - }, - }, - components: { - schemas: { - OrganizationSummaryListItem: { - type: "object", - properties: { - id: { type: "string" }, - slug: { type: "string" }, - name: { type: "string" }, - plan: { type: "string" }, - industry: { type: "string" }, - createdAt: { type: "string", format: "date-time" }, - projectCount: { type: "integer" }, - taskCount: { type: "integer" }, - openTaskCount: { type: "integer" }, - }, - }, - OrganizationDetail: { - allOf: [ - { $ref: "#/components/schemas/OrganizationSummaryListItem" }, - { - type: "object", - properties: { - userCount: { type: "integer" }, - statusBreakdown: { - type: "array", - items: { - type: "object", - properties: { - status: { type: "string" }, - count: { type: "integer" }, - }, - }, - }, - }, - }, - ], - }, - User: { - type: "object", - properties: { - id: { type: "string" }, - orgId: { type: "string" }, - fullName: { type: "string" }, - email: { type: "string", format: "email" }, - role: { type: "string" }, - timezone: { type: "string" }, - }, - }, - Project: { - type: "object", - properties: { - id: { type: "string" }, - orgId: { type: "string" }, - key: { type: "string" }, - name: { type: "string" }, - status: { type: "string" }, - ownerUserId: { type: "string" }, - ownerName: { type: ["string", "null"] }, - dueDate: { type: ["string", "null"], format: "date" }, - taskCount: { type: "integer" }, - }, - }, - TaskListItem: { - type: "object", - properties: taskProperties, - }, - Label: { - type: "object", - properties: { - id: { type: "string" }, - name: { type: "string" }, - color: { type: "string" }, - }, - }, - Comment: { - type: "object", - properties: { - id: { type: "string" }, - authorUserId: { type: "string" }, - authorName: { type: "string" }, - body: { type: "string" }, - createdAt: { type: "string", format: "date-time" }, - }, - }, - TaskDetail: { - allOf: [ - { $ref: "#/components/schemas/TaskListItem" }, - { - type: "object", - properties: { - labels: { - type: "array", - items: { $ref: "#/components/schemas/Label" }, - }, - comments: { - type: "array", - items: { $ref: "#/components/schemas/Comment" }, - }, - }, - }, - ], - }, - CreateTaskRequest: { - type: "object", - required: ["orgId", "projectId", "title"], - properties: { - orgId: { type: "string" }, - projectId: { type: "string" }, - assigneeUserId: { type: ["string", "null"] }, - title: { type: "string" }, - description: { type: "string" }, - status: { type: "string" }, - priority: { type: "string" }, - storyPoints: { type: ["integer", "null"] }, - dueDate: { type: ["string", "null"], format: "date" }, - }, - }, - UpdateTaskRequest: { - type: "object", - properties: { - title: { type: "string" }, - description: { type: "string" }, - status: { type: "string" }, - priority: { type: "string" }, - assigneeUserId: { type: ["string", "null"] }, - storyPoints: { type: ["integer", "null"] }, - dueDate: { type: ["string", "null"], format: "date" }, - }, - }, - }, - }, + tags: buildTags(), + paths: buildPaths(), + components, } as const; diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..04ee33f --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,740 @@ +import type { Database } from "bun:sqlite"; +import { + createTask, + getOrganizationSummary, + getTaskDetail, + listOrganizations, + listProjects, + listTasks, + listUsers, + updateTask, +} from "./repository"; + +export type HttpMethod = "GET" | "POST" | "PATCH"; +export type OpenApiSchema = Record; + +interface JsonSchemaContent { + "application/json": { + schema: OpenApiSchema; + }; +} + +interface OpenApiRequestBody { + required?: boolean; + content: JsonSchemaContent; +} + +interface OpenApiResponse { + description: string; + content?: JsonSchemaContent; +} + +interface OpenApiParameter { + name: string; + in: "path" | "query"; + required?: boolean; + schema: OpenApiSchema; +} + +export interface RouteHandlerContext { + db: Database; + request: Request; + url: URL; + params: Record; + body: TBody; +} + +type ValidationResult = { value: T } | { error: string }; + +export interface RouteDefinition { + method: HttpMethod; + path: string; + operationId: string; + summary: string; + description?: string; + tags: string[]; + parameters?: OpenApiParameter[]; + requestBody?: OpenApiRequestBody; + responses: Record; + parseBody?: (body: unknown) => ValidationResult; + servesOpenApiDocument?: boolean; + handler?: (context: RouteHandlerContext) => Response | Promise; +} + +export function json(body: unknown, init: ResponseInit = {}) { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json; charset=utf-8"); + headers.set("access-control-allow-origin", "*"); + headers.set("access-control-allow-methods", "GET,POST,PATCH,OPTIONS"); + headers.set("access-control-allow-headers", "content-type"); + return new Response(JSON.stringify(body, null, 2), { ...init, headers }); +} + +export function notFound(message: string) { + return json({ error: message }, { status: 404 }); +} + +export function badRequest(message: string) { + return json({ error: message }, { status: 400 }); +} + +export async function readJson(request: Request) { + try { + return await request.json(); + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function optionalText(value: unknown) { + return typeof value === "string" ? value.trim() : undefined; +} + +function nullableText(value: unknown) { + if (typeof value === "string") { + return value.trim(); + } + + return value === null ? null : undefined; +} + +function nullableInteger(value: unknown) { + if (typeof value === "number") { + return Math.trunc(value); + } + + return value === null ? null : undefined; +} + +function validateCreateTask(body: unknown): ValidationResult<{ + orgId: string; + projectId: string; + assigneeUserId: string | null; + title: string; + description?: string; + status?: string; + priority?: string; + storyPoints?: number | null; + dueDate?: string | null; +}> { + if (!isRecord(body)) { + return { error: "Request body must be a JSON object." }; + } + + if (typeof body.orgId !== "string" || body.orgId.trim() === "") { + return { error: "orgId is required." }; + } + if (typeof body.projectId !== "string" || body.projectId.trim() === "") { + return { error: "projectId is required." }; + } + if (typeof body.title !== "string" || body.title.trim() === "") { + return { error: "title is required." }; + } + + return { + value: { + orgId: body.orgId.trim(), + projectId: body.projectId.trim(), + assigneeUserId: typeof body.assigneeUserId === "string" ? body.assigneeUserId.trim() : null, + title: body.title.trim(), + description: optionalText(body.description), + status: optionalText(body.status), + priority: optionalText(body.priority), + storyPoints: nullableInteger(body.storyPoints), + dueDate: typeof body.dueDate === "string" ? body.dueDate : body.dueDate === null ? null : undefined, + }, + }; +} + +function validateUpdateTask(body: unknown): ValidationResult<{ + title?: string; + description?: string; + status?: string; + priority?: string; + assigneeUserId?: string | null; + storyPoints?: number | null; + dueDate?: string | null; +}> { + if (!isRecord(body)) { + return { error: "Request body must be a JSON object." }; + } + + return { + value: { + title: optionalText(body.title), + description: optionalText(body.description), + status: optionalText(body.status), + priority: optionalText(body.priority), + assigneeUserId: nullableText(body.assigneeUserId), + storyPoints: nullableInteger(body.storyPoints), + dueDate: typeof body.dueDate === "string" ? body.dueDate : body.dueDate === null ? null : undefined, + }, + }; +} + +const nullableString = { type: "string", nullable: true } as const; +const nullableIntegerSchema = { type: "integer", nullable: true } as const; +const nullableDate = { type: "string", format: "date", nullable: true } as const; + +const taskProperties = { + id: { type: "string" }, + orgId: { type: "string" }, + projectId: { type: "string" }, + assigneeUserId: nullableString, + title: { type: "string" }, + description: { type: "string" }, + status: { type: "string", enum: ["todo", "in_progress", "in_review", "blocked", "done"] }, + priority: { type: "string", enum: ["low", "medium", "high", "urgent"] }, + storyPoints: nullableIntegerSchema, + dueDate: nullableDate, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + projectName: { type: "string" }, + projectKey: { type: "string" }, + assigneeName: nullableString, +} as const; + +const organizationListResponse = { + type: "object", + required: ["organizations"], + properties: { + organizations: { + type: "array", + items: { $ref: "#/components/schemas/OrganizationSummaryListItem" }, + }, + }, +} as const; + +const organizationDetailResponse = { + type: "object", + required: ["organization"], + properties: { + organization: { $ref: "#/components/schemas/OrganizationDetail" }, + }, +} as const; + +const projectsResponse = { + type: "object", + required: ["projects"], + properties: { + projects: { + type: "array", + items: { $ref: "#/components/schemas/Project" }, + }, + }, +} as const; + +const usersResponse = { + type: "object", + required: ["users"], + properties: { + users: { + type: "array", + items: { $ref: "#/components/schemas/User" }, + }, + }, +} as const; + +const tasksResponse = { + type: "object", + required: ["tasks"], + properties: { + tasks: { + type: "array", + items: { $ref: "#/components/schemas/TaskListItem" }, + }, + }, +} as const; + +const taskResponse = { + type: "object", + required: ["task"], + properties: { + task: { $ref: "#/components/schemas/TaskDetail" }, + }, +} as const; + +const healthResponse = { + type: "object", + required: ["status"], + properties: { + status: { type: "string", enum: ["ok"] }, + }, +} as const; + +function jsonContent(schema: OpenApiSchema): JsonSchemaContent { + return { + "application/json": { + schema, + }, + }; +} + +function jsonResponse(description: string, schema: OpenApiSchema): OpenApiResponse { + return { + description, + content: jsonContent(schema), + }; +} + +function jsonRequestBody(schema: OpenApiSchema, required = true): OpenApiRequestBody { + return { + required, + content: jsonContent(schema), + }; +} + +export const components = { + schemas: { + OrganizationSummaryListItem: { + type: "object", + properties: { + id: { type: "string" }, + slug: { type: "string" }, + name: { type: "string" }, + plan: { type: "string" }, + industry: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + projectCount: { type: "integer" }, + taskCount: { type: "integer" }, + openTaskCount: { type: "integer" }, + }, + }, + OrganizationDetail: { + allOf: [ + { $ref: "#/components/schemas/OrganizationSummaryListItem" }, + { + type: "object", + properties: { + userCount: { type: "integer" }, + statusBreakdown: { + type: "array", + items: { + type: "object", + properties: { + status: { type: "string" }, + count: { type: "integer" }, + }, + }, + }, + }, + }, + ], + }, + User: { + type: "object", + properties: { + id: { type: "string" }, + orgId: { type: "string" }, + fullName: { type: "string" }, + email: { type: "string", format: "email" }, + role: { type: "string" }, + timezone: { type: "string" }, + }, + }, + Project: { + type: "object", + properties: { + id: { type: "string" }, + orgId: { type: "string" }, + key: { type: "string" }, + name: { type: "string" }, + status: { type: "string" }, + ownerUserId: { type: "string" }, + ownerName: nullableString, + dueDate: nullableDate, + taskCount: { type: "integer" }, + }, + }, + TaskListItem: { + type: "object", + properties: taskProperties, + }, + Label: { + type: "object", + properties: { + id: { type: "string" }, + name: { type: "string" }, + color: { type: "string" }, + }, + }, + Comment: { + type: "object", + properties: { + id: { type: "string" }, + authorUserId: { type: "string" }, + authorName: { type: "string" }, + body: { type: "string" }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + TaskDetail: { + allOf: [ + { $ref: "#/components/schemas/TaskListItem" }, + { + type: "object", + properties: { + labels: { + type: "array", + items: { $ref: "#/components/schemas/Label" }, + }, + comments: { + type: "array", + items: { $ref: "#/components/schemas/Comment" }, + }, + }, + }, + ], + }, + CreateTaskRequest: { + type: "object", + required: ["orgId", "projectId", "title"], + properties: { + orgId: { type: "string" }, + projectId: { type: "string" }, + assigneeUserId: nullableString, + title: { type: "string" }, + description: { type: "string" }, + status: { type: "string" }, + priority: { type: "string" }, + storyPoints: nullableIntegerSchema, + dueDate: nullableDate, + }, + }, + UpdateTaskRequest: { + type: "object", + properties: { + title: { type: "string" }, + description: { type: "string" }, + status: { type: "string" }, + priority: { type: "string" }, + assigneeUserId: nullableString, + storyPoints: nullableIntegerSchema, + dueDate: nullableDate, + }, + }, + }, +} as const; + +export const routes: RouteDefinition[] = [ + { + method: "GET", + path: "/health", + operationId: "getHealth", + summary: "Check service health", + tags: ["Health"], + responses: { + "200": jsonResponse("Service is healthy", healthResponse), + }, + handler: () => json({ status: "ok" }), + }, + { + method: "GET", + path: "/orgs", + operationId: "listOrganizations", + summary: "List organizations", + tags: ["Organizations"], + responses: { + "200": jsonResponse("Organizations with summary counts", organizationListResponse), + }, + handler: ({ db }) => json({ organizations: listOrganizations(db) }), + }, + { + method: "GET", + path: "/orgs/{orgId}", + operationId: "getOrganization", + summary: "Get organization detail", + tags: ["Organizations"], + parameters: [ + { + name: "orgId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": jsonResponse("Organization detail and task status breakdown", organizationDetailResponse), + "404": { description: "Organization not found" }, + }, + handler: ({ db, params }) => { + const summary = getOrganizationSummary(db, params.orgId); + return summary ? json({ organization: summary }) : notFound("Organization not found."); + }, + }, + { + method: "GET", + path: "/orgs/{orgId}/projects", + operationId: "listOrganizationProjects", + summary: "List projects for an organization", + tags: ["Projects"], + parameters: [ + { + name: "orgId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": jsonResponse("Projects for the org", projectsResponse), + }, + handler: ({ db, params }) => json({ projects: listProjects(db, params.orgId) }), + }, + { + method: "GET", + path: "/orgs/{orgId}/tasks", + operationId: "listOrganizationTasks", + summary: "List tasks for an organization", + tags: ["Tasks"], + parameters: [ + { + name: "orgId", + in: "path", + required: true, + schema: { type: "string" }, + }, + { + name: "projectId", + in: "query", + schema: { type: "string" }, + }, + { + name: "status", + in: "query", + schema: { type: "string" }, + }, + { + name: "priority", + in: "query", + schema: { type: "string" }, + }, + { + name: "assigneeUserId", + in: "query", + schema: { type: "string" }, + }, + ], + responses: { + "200": jsonResponse("Filtered org task list", tasksResponse), + }, + handler: ({ db, params, url }) => + json({ + tasks: listTasks(db, { + orgId: params.orgId, + projectId: url.searchParams.get("projectId"), + status: url.searchParams.get("status"), + priority: url.searchParams.get("priority"), + assigneeUserId: url.searchParams.get("assigneeUserId"), + }), + }), + }, + { + method: "GET", + path: "/users", + operationId: "listUsers", + summary: "List users", + tags: ["Users"], + parameters: [ + { + name: "orgId", + in: "query", + schema: { type: "string" }, + }, + ], + responses: { + "200": jsonResponse("User list", usersResponse), + }, + handler: ({ db, url }) => json({ users: listUsers(db, url.searchParams.get("orgId")) }), + }, + { + method: "GET", + path: "/projects", + operationId: "listProjects", + summary: "List projects", + tags: ["Projects"], + parameters: [ + { + name: "orgId", + in: "query", + schema: { type: "string" }, + }, + ], + responses: { + "200": jsonResponse("Project list", projectsResponse), + }, + handler: ({ db, url }) => json({ projects: listProjects(db, url.searchParams.get("orgId")) }), + }, + { + method: "GET", + path: "/tasks", + operationId: "listTasks", + summary: "List tasks", + tags: ["Tasks"], + parameters: [ + { name: "orgId", in: "query", schema: { type: "string" } }, + { name: "projectId", in: "query", schema: { type: "string" } }, + { name: "status", in: "query", schema: { type: "string" } }, + { name: "priority", in: "query", schema: { type: "string" } }, + { name: "assigneeUserId", in: "query", schema: { type: "string" } }, + ], + responses: { + "200": jsonResponse("Filtered task list", tasksResponse), + }, + handler: ({ db, url }) => + json({ + tasks: listTasks(db, { + orgId: url.searchParams.get("orgId"), + projectId: url.searchParams.get("projectId"), + status: url.searchParams.get("status"), + priority: url.searchParams.get("priority"), + assigneeUserId: url.searchParams.get("assigneeUserId"), + }), + }), + }, + { + method: "POST", + path: "/tasks", + operationId: "createTask", + summary: "Create a task", + tags: ["Tasks"], + requestBody: jsonRequestBody({ $ref: "#/components/schemas/CreateTaskRequest" }), + responses: { + "201": jsonResponse("Created task detail", taskResponse), + "400": { description: "Invalid payload" }, + }, + parseBody: validateCreateTask, + handler: ({ db, body }) => json({ task: createTask(db, body) }, { status: 201 }), + }, + { + method: "GET", + path: "/tasks/{taskId}", + operationId: "getTask", + summary: "Get task detail", + tags: ["Tasks"], + parameters: [ + { + name: "taskId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + responses: { + "200": jsonResponse("Task detail with labels and comments", taskResponse), + "404": { description: "Task not found" }, + }, + handler: ({ db, params }) => { + const task = getTaskDetail(db, params.taskId); + return task ? json({ task }) : notFound("Task not found."); + }, + }, + { + method: "PATCH", + path: "/tasks/{taskId}", + operationId: "updateTask", + summary: "Update a task", + tags: ["Tasks"], + parameters: [ + { + name: "taskId", + in: "path", + required: true, + schema: { type: "string" }, + }, + ], + requestBody: jsonRequestBody({ $ref: "#/components/schemas/UpdateTaskRequest" }), + responses: { + "200": jsonResponse("Updated task detail", taskResponse), + "404": { description: "Task not found" }, + }, + parseBody: validateUpdateTask, + handler: ({ db, params, body }) => { + const task = updateTask(db, params.taskId, body); + return task ? json({ task }) : notFound("Task not found."); + }, + }, + { + method: "GET", + path: "/openapi.json", + operationId: "getOpenApiDocument", + summary: "Get OpenAPI document", + tags: ["Health"], + responses: { + "200": { description: "OpenAPI specification" }, + }, + servesOpenApiDocument: true, + }, + { + method: "GET", + path: "/swagger.json", + operationId: "getSwaggerAlias", + summary: "Get OpenAPI document via swagger alias", + tags: ["Health"], + responses: { + "200": { description: "OpenAPI specification" }, + }, + servesOpenApiDocument: true, + }, + { + method: "GET", + path: "/api-docs", + operationId: "getApiDocsAlias", + summary: "Get OpenAPI document via api-docs alias", + tags: ["Health"], + responses: { + "200": { description: "OpenAPI specification" }, + }, + servesOpenApiDocument: true, + }, +]; + +function matchPath(pathTemplate: string, pathname: string) { + const templateParts = pathTemplate.split("/").filter(Boolean); + const pathParts = pathname.split("/").filter(Boolean); + + if (templateParts.length !== pathParts.length) { + return null; + } + + const params: Record = {}; + + for (let index = 0; index < templateParts.length; index += 1) { + const templatePart = templateParts[index]; + const pathPart = pathParts[index]; + + if (!templatePart || !pathPart) { + return null; + } + + if (templatePart.startsWith("{") && templatePart.endsWith("}")) { + params[templatePart.slice(1, -1)] = decodeURIComponent(pathPart); + continue; + } + + if (templatePart !== pathPart) { + return null; + } + } + + return params; +} + +export function matchRoute(method: string, pathname: string) { + for (const route of routes) { + if (route.method !== method) { + continue; + } + + const params = matchPath(route.path, pathname); + if (params) { + return { route, params }; + } + } + + return null; +} diff --git a/tests/api.test.ts b/tests/api.test.ts index 38656f1..823006e 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { reseedDatabase } from "../src/database"; import { createServer } from "../src/index"; +import { openApiDocument } from "../src/openapi"; let tempDir: string | null = null; @@ -101,6 +102,53 @@ describe("mock task API", () => { expect((body.task as Record).assigneeUserId).toBeNull(); }); + it("returns 400 for invalid create payloads", async () => { + const server = createTestServer(); + + const response = await server.fetch( + new Request("http://mock.local/tasks", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + orgId: "org_acme", + projectId: "proj_acme_ops", + }), + }), + ); + const body = await readJson(response); + + expect(response.status).toBe(400); + expect(body.error).toBe("title is required."); + }); + + it("returns 404 when updating a missing task", async () => { + const server = createTestServer(); + + const response = await server.fetch( + new Request("http://mock.local/tasks/task_missing", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + status: "done", + }), + }), + ); + const body = await readJson(response); + + expect(response.status).toBe(404); + expect(body.error).toBe("Task not found."); + }); + + it("returns 404 for unknown routes", async () => { + const server = createTestServer(); + + const response = await server.fetch(new Request("http://mock.local/unknown")); + const body = await readJson(response); + + expect(response.status).toBe(404); + expect(body.error).toBe("Route not found."); + }); + it("returns a valid openapi document at the import path", async () => { const server = createTestServer(); @@ -109,11 +157,34 @@ describe("mock task API", () => { const paths = body.paths as Record; expect(response.status).toBe(200); - expect(body.openapi).toBe("3.1.0"); + expect(body.openapi).toBe("3.0.3"); expect(paths["/tasks"]).toBeDefined(); expect(paths["/orgs/{orgId}/tasks"]).toBeDefined(); }); + it("advertises every implemented route in the openapi document", () => { + const operations = Object.entries(openApiDocument.paths).flatMap(([path, pathItem]) => + Object.keys(pathItem).map((method) => `${method.toUpperCase()} ${path}`), + ); + + expect(operations).toEqual([ + "GET /health", + "GET /orgs", + "GET /orgs/{orgId}", + "GET /orgs/{orgId}/projects", + "GET /orgs/{orgId}/tasks", + "GET /users", + "GET /projects", + "GET /tasks", + "POST /tasks", + "GET /tasks/{taskId}", + "PATCH /tasks/{taskId}", + "GET /openapi.json", + "GET /swagger.json", + "GET /api-docs", + ]); + }); + it("supports the swagger alias path used by import tools", async () => { const server = createTestServer(); @@ -121,4 +192,20 @@ describe("mock task API", () => { expect(response.status).toBe(200); }); + + it("serves the same generated document on all openapi aliases", async () => { + const server = createTestServer(); + + const openApiResponse = await server.fetch(new Request("http://mock.local/openapi.json")); + const swaggerResponse = await server.fetch(new Request("http://mock.local/swagger.json")); + const apiDocsResponse = await server.fetch(new Request("http://mock.local/api-docs")); + + const openApiBody = await readJson(openApiResponse); + const swaggerBody = await readJson(swaggerResponse); + const apiDocsBody = await readJson(apiDocsResponse); + + expect(openApiBody).toEqual(swaggerBody); + expect(swaggerBody).toEqual(apiDocsBody); + expect(openApiBody).toEqual(openApiDocument); + }); }); diff --git a/tests/routes.test.ts b/tests/routes.test.ts new file mode 100644 index 0000000..6b228f8 --- /dev/null +++ b/tests/routes.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "bun:test"; +import { openApiDocument } from "../src/openapi"; +import { matchRoute, routes } from "../src/routes"; + +describe("route registry", () => { + it("matches concrete paths and extracts route params", () => { + const matched = matchRoute("GET", "/orgs/org_acme/tasks"); + + expect(matched).not.toBeNull(); + expect(matched?.route.operationId).toBe("listOrganizationTasks"); + expect(matched?.params).toEqual({ orgId: "org_acme" }); + }); + + it("returns null for unsupported methods and unknown paths", () => { + expect(matchRoute("DELETE", "/tasks/task_1001")).toBeNull(); + expect(matchRoute("GET", "/does-not-exist")).toBeNull(); + }); + + it("builds an openapi path entry for every registered route", () => { + const registeredOperations = routes.map( + (route) => `${route.method} ${route.path}:${route.operationId}`, + ); + const documentedOperations = Object.entries(openApiDocument.paths).flatMap( + ([path, pathItem]) => + Object.entries(pathItem).map( + ([method, operation]) => + `${method.toUpperCase()} ${path}:${(operation as { operationId?: string }).operationId ?? ""}`, + ), + ); + + expect(documentedOperations).toEqual(registeredOperations); + }); + + it("keeps spec-serving routes explicit in the registry", () => { + const openApiAliases = routes + .filter((route) => route.servesOpenApiDocument) + .map((route) => route.path); + + expect(openApiAliases).toEqual(["/openapi.json", "/swagger.json", "/api-docs"]); + }); +});