updated openapi

This commit is contained in:
2026-03-03 14:49:12 +00:00
parent 4328ada595
commit 5e3726de39
6 changed files with 937 additions and 740 deletions

View File

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

View File

@@ -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) });
}
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,
});
},
};
}

View File

@@ -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
View 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;
}

View File

@@ -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
View 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"]);
});
});