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
|
- task, project, user, and org summary endpoints
|
||||||
- write endpoints for creating tasks and updating task state
|
- write endpoints for creating tasks and updating task state
|
||||||
- a small test suite covering seeded reads and task mutations
|
- 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
|
- standalone Docker and Compose files for running the mock API by itself
|
||||||
|
|
||||||
## Domain Model
|
## Domain Model
|
||||||
@@ -140,8 +140,9 @@ docker compose up --build
|
|||||||
|
|
||||||
## Files That Matter
|
## Files That Matter
|
||||||
|
|
||||||
- `src/index.ts`: HTTP routes and server startup
|
- `src/index.ts`: server startup and route dispatch
|
||||||
- `src/openapi.ts`: importable OpenAPI 3.1 document
|
- `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/database.ts`: schema creation and seed data
|
||||||
- `src/repository.ts`: query and mutation helpers
|
- `src/repository.ts`: query and mutation helpers
|
||||||
- `scripts/seed.ts`: resets and reseeds the SQLite file
|
- `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 { getConfig } from "./config";
|
||||||
import { ensureSeededDatabase } from "./database";
|
import { ensureSeededDatabase } from "./database";
|
||||||
import { openApiDocument } from "./openapi";
|
import { openApiDocument } from "./openapi";
|
||||||
import {
|
import { badRequest, json, matchRoute, notFound, readJson } from "./routes";
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createServer(db: Database) {
|
export function createServer(db: Database) {
|
||||||
return {
|
return {
|
||||||
@@ -118,92 +13,39 @@ export function createServer(db: Database) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(request.url);
|
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") {
|
if (!matched) {
|
||||||
return json({ status: "ok" });
|
return notFound("Route not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const { route, params } = matched;
|
||||||
request.method === "GET" &&
|
|
||||||
(url.pathname === "/openapi.json" ||
|
if (route.servesOpenApiDocument) {
|
||||||
url.pathname === "/swagger.json" ||
|
|
||||||
url.pathname === "/api-docs")
|
|
||||||
) {
|
|
||||||
return json(openApiDocument);
|
return json(openApiDocument);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "GET" && url.pathname === "/orgs") {
|
let body: unknown = undefined;
|
||||||
return json({ organizations: listOrganizations(db) });
|
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") {
|
body = parsed.value;
|
||||||
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.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!route.handler) {
|
||||||
return notFound("Route not found.");
|
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 = {
|
import { components, routes } from "./routes";
|
||||||
id: { type: "string" },
|
|
||||||
orgId: { type: "string" },
|
function buildPaths() {
|
||||||
projectId: { type: "string" },
|
const paths: Record<string, Record<string, unknown>> = {};
|
||||||
assigneeUserId: { type: ["string", "null"] },
|
|
||||||
title: { type: "string" },
|
for (const route of routes) {
|
||||||
description: { type: "string" },
|
if (!paths[route.path]) {
|
||||||
status: { type: "string", enum: ["todo", "in_progress", "in_review", "blocked", "done"] },
|
paths[route.path] = {};
|
||||||
priority: { type: "string", enum: ["low", "medium", "high", "urgent"] },
|
}
|
||||||
storyPoints: { type: ["integer", "null"] },
|
|
||||||
dueDate: { type: ["string", "null"], format: "date" },
|
paths[route.path]![route.method.toLowerCase()] = {
|
||||||
createdAt: { type: "string", format: "date-time" },
|
operationId: route.operationId,
|
||||||
updatedAt: { type: "string", format: "date-time" },
|
tags: route.tags,
|
||||||
projectName: { type: "string" },
|
summary: route.summary,
|
||||||
projectKey: { type: "string" },
|
...(route.description ? { description: route.description } : {}),
|
||||||
assigneeName: { type: ["string", "null"] },
|
...(route.parameters ? { parameters: route.parameters } : {}),
|
||||||
} as const;
|
...(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 = {
|
export const openApiDocument = {
|
||||||
openapi: "3.1.0",
|
openapi: "3.0.3",
|
||||||
info: {
|
info: {
|
||||||
title: "POC Mock Task API",
|
title: "POC Mock Task API",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
@@ -30,542 +51,7 @@ export const openApiDocument = {
|
|||||||
description: "Local development server",
|
description: "Local development server",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
tags: [
|
tags: buildTags(),
|
||||||
{ name: "Health" },
|
paths: buildPaths(),
|
||||||
{ name: "Organizations" },
|
components,
|
||||||
{ 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" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
} 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 { tmpdir } from "node:os";
|
||||||
import { reseedDatabase } from "../src/database";
|
import { reseedDatabase } from "../src/database";
|
||||||
import { createServer } from "../src/index";
|
import { createServer } from "../src/index";
|
||||||
|
import { openApiDocument } from "../src/openapi";
|
||||||
|
|
||||||
let tempDir: string | null = null;
|
let tempDir: string | null = null;
|
||||||
|
|
||||||
@@ -101,6 +102,53 @@ describe("mock task API", () => {
|
|||||||
expect((body.task as Record<string, unknown>).assigneeUserId).toBeNull();
|
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 () => {
|
it("returns a valid openapi document at the import path", async () => {
|
||||||
const server = createTestServer();
|
const server = createTestServer();
|
||||||
|
|
||||||
@@ -109,11 +157,34 @@ describe("mock task API", () => {
|
|||||||
const paths = body.paths as Record<string, unknown>;
|
const paths = body.paths as Record<string, unknown>;
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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["/tasks"]).toBeDefined();
|
||||||
expect(paths["/orgs/{orgId}/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 () => {
|
it("supports the swagger alias path used by import tools", async () => {
|
||||||
const server = createTestServer();
|
const server = createTestServer();
|
||||||
|
|
||||||
@@ -121,4 +192,20 @@ describe("mock task API", () => {
|
|||||||
|
|
||||||
expect(response.status).toBe(200);
|
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