updated openapi
This commit is contained in:
@@ -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
|
||||
|
||||
204
src/index.ts
204
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<string, unknown> {
|
||||
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) });
|
||||
let body: unknown = undefined;
|
||||
if (route.parseBody) {
|
||||
const parsed = route.parseBody(await readJson(request));
|
||||
if ("error" in parsed) {
|
||||
return badRequest(parsed.error);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const task = createTask(db, payload.value);
|
||||
return json({ task }, { status: 201 });
|
||||
}
|
||||
|
||||
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 (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.");
|
||||
body = parsed.value;
|
||||
}
|
||||
|
||||
if (!route.handler) {
|
||||
return notFound("Route not found.");
|
||||
}
|
||||
|
||||
return route.handler({
|
||||
db,
|
||||
request,
|
||||
url,
|
||||
params,
|
||||
body,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
598
src/openapi.ts
598
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<string, Record<string, unknown>> = {};
|
||||
|
||||
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<string>();
|
||||
|
||||
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;
|
||||
|
||||
740
src/routes.ts
Normal file
740
src/routes.ts
Normal file
@@ -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<string, unknown>;
|
||||
|
||||
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<TBody = unknown> {
|
||||
db: Database;
|
||||
request: Request;
|
||||
url: URL;
|
||||
params: Record<string, string>;
|
||||
body: TBody;
|
||||
}
|
||||
|
||||
type ValidationResult<T> = { value: T } | { error: string };
|
||||
|
||||
export interface RouteDefinition<TBody = unknown> {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
operationId: string;
|
||||
summary: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
parameters?: OpenApiParameter[];
|
||||
requestBody?: OpenApiRequestBody;
|
||||
responses: Record<string, OpenApiResponse>;
|
||||
parseBody?: (body: unknown) => ValidationResult<TBody>;
|
||||
servesOpenApiDocument?: boolean;
|
||||
handler?: (context: RouteHandlerContext<TBody>) => Response | Promise<Response>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
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<string, string> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown>).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<string, unknown>;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
41
tests/routes.test.ts
Normal file
41
tests/routes.test.ts
Normal file
@@ -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"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user