@critical-path/server 0.23.4 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +1 -0
- package/dist/openapi.js.map +1 -1
- package/dist/router.d.ts +8 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +33 -1
- package/dist/router.js.map +1 -1
- package/package.json +12 -8
- package/CHANGELOG.md +0 -678
- package/dist/index.test.d.ts +0 -2
- package/dist/index.test.d.ts.map +0 -1
- package/dist/index.test.js +0 -861
- package/dist/index.test.js.map +0 -1
- package/dist/openapi.test.d.ts +0 -2
- package/dist/openapi.test.d.ts.map +0 -1
- package/dist/openapi.test.js +0 -91
- package/dist/openapi.test.js.map +0 -1
- package/src/adapters/next.ts +0 -42
- package/src/adapters/sveltekit.ts +0 -38
- package/src/adapters/universal.ts +0 -16
- package/src/index.test.ts +0 -1028
- package/src/index.ts +0 -5
- package/src/openapi.test.ts +0 -102
- package/src/openapi.ts +0 -186
- package/src/router.ts +0 -895
- package/tsconfig.json +0 -8
- package/tsconfig.tsbuildinfo +0 -1
package/src/index.ts
DELETED
package/src/openapi.test.ts
DELETED
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { CriticalPathRouter } from './router.js';
|
|
3
|
-
import { ROUTES, buildOpenApiDocument } from './openapi.js';
|
|
4
|
-
|
|
5
|
-
const base = 'http://localhost:3000/api/critical-path';
|
|
6
|
-
|
|
7
|
-
describe('OpenAPI document', () => {
|
|
8
|
-
it('documents every route the router serves, with matching response envelopes', async () => {
|
|
9
|
-
const router = new CriticalPathRouter();
|
|
10
|
-
const engine = router.engine;
|
|
11
|
-
const project = await engine.createProject({ key: 'OAS', name: 'OpenAPI' });
|
|
12
|
-
const task = await engine.createTask({ projectId: project.id, title: 'Task' });
|
|
13
|
-
const workflow = await engine.createWorkflow({ name: 'WF', statuses: [], transitions: [] });
|
|
14
|
-
const team = await engine.createTeam({ name: 'Team', memberIds: [] });
|
|
15
|
-
const container = await engine.createContainer({ projectId: project.id, name: 'Epic' });
|
|
16
|
-
const deliverable = await engine.createDeliverable({ projectId: project.id, title: 'Cut' });
|
|
17
|
-
const iteration = await engine.createIteration({ projectId: project.id, name: 'S1', status: 'planning' });
|
|
18
|
-
const comment = await engine.addComment({ taskId: task.id, content: 'hi', authorId: 'u1' });
|
|
19
|
-
const attachment = await engine.createAttachment({
|
|
20
|
-
taskId: task.id,
|
|
21
|
-
uploaderId: 'u1',
|
|
22
|
-
filename: 'a.md',
|
|
23
|
-
mimeType: 'text/markdown',
|
|
24
|
-
sizeBytes: 1,
|
|
25
|
-
url: 'https://example.com/a.md'
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
const { webhook } = await engine.createWebhook({ name: 'Hook', url: 'https://hooks.example.com/oas', events: ['*'] });
|
|
29
|
-
|
|
30
|
-
const otherTask = await engine.createTask({ projectId: project.id, title: 'Upstream' });
|
|
31
|
-
const dependency = await engine.addDependency({ taskId: task.id, dependsOnTaskId: otherTask.id, type: 'blocking' });
|
|
32
|
-
|
|
33
|
-
const ids: Record<string, string> = {
|
|
34
|
-
dependencyId: dependency.id,
|
|
35
|
-
webhookId: webhook.id,
|
|
36
|
-
projectId: project.id,
|
|
37
|
-
taskId: task.id,
|
|
38
|
-
workflowId: workflow.id,
|
|
39
|
-
teamId: team.id,
|
|
40
|
-
containerId: container.id,
|
|
41
|
-
deliverableId: deliverable.id,
|
|
42
|
-
iterationId: iteration.id,
|
|
43
|
-
commentId: comment.id,
|
|
44
|
-
attachmentId: attachment.id
|
|
45
|
-
};
|
|
46
|
-
const query: Record<string, string> = { projectId: project.id, taskId: task.id, emoji: '👍', userId: 'u1' };
|
|
47
|
-
|
|
48
|
-
// Deletes run last so earlier routes still have data; the project goes last of all.
|
|
49
|
-
const ordered = [...ROUTES].sort((a, b) => {
|
|
50
|
-
const rank = (r: (typeof ROUTES)[number]) => (r.method === 'delete' ? (r.path === '/projects/{projectId}' ? 2 : 1) : 0);
|
|
51
|
-
return rank(a) - rank(b);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
for (const route of ordered) {
|
|
55
|
-
const path = route.path.replace(/\{(\w+)\}/g, (_, name) => ids[name]);
|
|
56
|
-
const params = new URLSearchParams();
|
|
57
|
-
for (const q of route.query ?? []) {
|
|
58
|
-
const name = q.replace(/!$/, '');
|
|
59
|
-
if (query[name]) params.set(name, query[name]);
|
|
60
|
-
}
|
|
61
|
-
const res = await router.handleRequest(
|
|
62
|
-
new Request(`${base}${path}${params.size ? `?${params}` : ''}`, {
|
|
63
|
-
method: route.method.toUpperCase(),
|
|
64
|
-
headers: { 'Content-Type': 'application/json' },
|
|
65
|
-
...(route.method === 'post' || route.method === 'patch' ? { body: '{}' } : {})
|
|
66
|
-
})
|
|
67
|
-
);
|
|
68
|
-
const body = await res.json();
|
|
69
|
-
const label = `${route.method.toUpperCase()} ${route.path}`;
|
|
70
|
-
expect(String(body.error ?? ''), label).not.toMatch(/^Route not found/);
|
|
71
|
-
if (res.status < 300 && route.responseKey) {
|
|
72
|
-
expect(body, label).toHaveProperty(route.responseKey);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('serves the document at /openapi.json with request bodies from the validation schemas', async () => {
|
|
78
|
-
const router = new CriticalPathRouter();
|
|
79
|
-
const res = await router.handleRequest(new Request(`${base}/openapi.json`));
|
|
80
|
-
expect(res.status).toBe(200);
|
|
81
|
-
const doc = await res.json();
|
|
82
|
-
|
|
83
|
-
expect(doc.openapi).toBe('3.1.0');
|
|
84
|
-
expect(doc.servers[0].url).toBe('http://localhost:3000/api/critical-path');
|
|
85
|
-
const createTask = doc.paths['/tasks'].post.requestBody.content['application/json'].schema;
|
|
86
|
-
expect(createTask.required).toEqual(expect.arrayContaining(['projectId', 'title']));
|
|
87
|
-
expect(createTask.properties).not.toHaveProperty('id');
|
|
88
|
-
expect(doc.paths['/tasks/{taskId}/dependencies'].post.responses).toHaveProperty('409');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('respects requireAuth', async () => {
|
|
92
|
-
const router = new CriticalPathRouter(undefined, { requireAuth: true });
|
|
93
|
-
const res = await router.handleRequest(new Request(`${base}/openapi.json`));
|
|
94
|
-
expect(res.status).toBe(401);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('can be built without a router for static publishing', () => {
|
|
98
|
-
const doc = buildOpenApiDocument({ serverUrl: 'https://api.example.com/pm', version: '2.0.0' }) as any;
|
|
99
|
-
expect(doc.info.version).toBe('2.0.0');
|
|
100
|
-
expect(Object.keys(doc.paths).length).toBeGreaterThan(30);
|
|
101
|
-
});
|
|
102
|
-
});
|
package/src/openapi.ts
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { zodToJsonSchema } from 'zod-to-json-schema';
|
|
2
|
-
import * as schemas from '@critical-path/core/schemas';
|
|
3
|
-
|
|
4
|
-
type Method = 'get' | 'post' | 'patch' | 'delete';
|
|
5
|
-
|
|
6
|
-
interface RouteDoc {
|
|
7
|
-
method: Method;
|
|
8
|
-
path: string;
|
|
9
|
-
summary: string;
|
|
10
|
-
tag: string;
|
|
11
|
-
/** Query parameters; names ending in `!` are required. */
|
|
12
|
-
query?: string[];
|
|
13
|
-
body?: { safeParse(data: unknown): unknown };
|
|
14
|
-
/** Key of the JSON response envelope, e.g. `task` for `{ "task": { ... } }`. */
|
|
15
|
-
responseKey?: string;
|
|
16
|
-
status?: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Every route served by `CriticalPathRouter`. Kept in sync by the route drift test. */
|
|
20
|
-
export const ROUTES: RouteDoc[] = [
|
|
21
|
-
{ method: 'get', path: '/webhooks', summary: 'List webhooks (secrets redacted)', tag: 'Webhooks', responseKey: 'webhooks' },
|
|
22
|
-
{ method: 'post', path: '/webhooks', summary: 'Register a webhook; the signing secret is returned once', tag: 'Webhooks', body: schemas.CreateWebhookSchema, responseKey: 'webhook', status: 201 },
|
|
23
|
-
{ method: 'get', path: '/webhooks/{webhookId}', summary: 'Get a webhook', tag: 'Webhooks', responseKey: 'webhook' },
|
|
24
|
-
{ method: 'patch', path: '/webhooks/{webhookId}', summary: 'Update or rotate a webhook', tag: 'Webhooks', body: schemas.UpdateWebhookSchema, responseKey: 'webhook' },
|
|
25
|
-
{ method: 'delete', path: '/webhooks/{webhookId}', summary: 'Delete a webhook', tag: 'Webhooks' },
|
|
26
|
-
|
|
27
|
-
{ method: 'get', path: '/workflows', summary: 'List workflows', tag: 'Workflows', responseKey: 'workflows' },
|
|
28
|
-
{ method: 'post', path: '/workflows', summary: 'Create a workflow', tag: 'Workflows', body: schemas.CreateWorkflowSchema, responseKey: 'workflow', status: 201 },
|
|
29
|
-
{ method: 'get', path: '/workflows/{workflowId}', summary: 'Get a workflow', tag: 'Workflows', responseKey: 'workflow' },
|
|
30
|
-
{ method: 'patch', path: '/workflows/{workflowId}', summary: 'Update a workflow', tag: 'Workflows', body: schemas.UpdateWorkflowSchema, responseKey: 'workflow' },
|
|
31
|
-
{ method: 'delete', path: '/workflows/{workflowId}', summary: 'Delete a workflow', tag: 'Workflows' },
|
|
32
|
-
|
|
33
|
-
{ method: 'get', path: '/projects', summary: 'List projects', tag: 'Projects', responseKey: 'projects' },
|
|
34
|
-
{ method: 'post', path: '/projects', summary: 'Create a project', tag: 'Projects', body: schemas.CreateProjectSchema, responseKey: 'project', status: 201 },
|
|
35
|
-
{ method: 'get', path: '/projects/{projectId}', summary: 'Get a project', tag: 'Projects', responseKey: 'project' },
|
|
36
|
-
{ method: 'patch', path: '/projects/{projectId}', summary: 'Update a project', tag: 'Projects', body: schemas.UpdateProjectSchema, responseKey: 'project' },
|
|
37
|
-
{ method: 'delete', path: '/projects/{projectId}', summary: 'Delete a project and its tasks', tag: 'Projects' },
|
|
38
|
-
{ method: 'get', path: '/projects/{projectId}/critical-path', summary: 'Critical path (CPM) analysis', tag: 'Analysis', responseKey: 'analysis' },
|
|
39
|
-
{ method: 'get', path: '/projects/{projectId}/ladder', summary: 'Timeline ladder of abstraction', tag: 'Analysis', query: ['level', 'containerId', 'iterationId'], responseKey: 'ladder' },
|
|
40
|
-
{ method: 'get', path: '/projects/{projectId}/workload', summary: 'Project workload distribution', tag: 'Analysis', query: ['startDate', 'endDate', 'interval', 'groupBy', 'metric', 'defaultWeeklyCapacityHours'], responseKey: 'workload' },
|
|
41
|
-
|
|
42
|
-
{ method: 'get', path: '/tasks', summary: 'List tasks (filtered, paginated oldest first; follow nextCursor)', tag: 'Tasks', query: ['projectId', 'status', 'priority', 'assigneeId', 'iterationId', 'deliverableId', 'containerId', 'parentId', 'limit', 'cursor'], responseKey: 'tasks' },
|
|
43
|
-
{ method: 'post', path: '/tasks', summary: 'Create a task', tag: 'Tasks', body: schemas.CreateTaskSchema, responseKey: 'task', status: 201 },
|
|
44
|
-
{ method: 'get', path: '/tasks/{taskId}', summary: 'Get a task', tag: 'Tasks', responseKey: 'task' },
|
|
45
|
-
{ method: 'patch', path: '/tasks/{taskId}', summary: 'Update a task (enforces workflow transitions)', tag: 'Tasks', body: schemas.UpdateTaskSchema, responseKey: 'task' },
|
|
46
|
-
{ method: 'delete', path: '/tasks/{taskId}', summary: 'Delete a task with its subtasks, dependencies, comments, attachments and time entries (subtasks=detach keeps subtasks)', tag: 'Tasks', query: ['subtasks'] },
|
|
47
|
-
{ method: 'get', path: '/tasks/{taskId}/comments', summary: 'List task comments', tag: 'Comments', responseKey: 'comments' },
|
|
48
|
-
{ method: 'post', path: '/tasks/{taskId}/comments', summary: 'Comment on a task', tag: 'Comments', body: schemas.CreateCommentSchema.omit({ taskId: true }), responseKey: 'comment', status: 201 },
|
|
49
|
-
{ method: 'get', path: '/tasks/{taskId}/attachments', summary: 'List task attachments', tag: 'Attachments', responseKey: 'attachments' },
|
|
50
|
-
{ method: 'post', path: '/tasks/{taskId}/attachments', summary: 'Attach a file link to a task', tag: 'Attachments', body: schemas.CreateAttachmentSchema.omit({ taskId: true }), responseKey: 'attachment', status: 201 },
|
|
51
|
-
{ method: 'get', path: '/tasks/{taskId}/dependencies', summary: 'Task dependency graph', tag: 'Dependencies', responseKey: 'graph' },
|
|
52
|
-
{ method: 'post', path: '/tasks/{taskId}/dependencies', summary: 'Add a dependency (409 on cycles)', tag: 'Dependencies', body: schemas.CreateDependencySchema, responseKey: 'dependency', status: 201 },
|
|
53
|
-
{ method: 'delete', path: '/tasks/{taskId}/dependencies/{dependencyId}', summary: 'Remove a dependency', tag: 'Dependencies' },
|
|
54
|
-
{ method: 'get', path: '/tasks/{taskId}/lifecycle', summary: 'Derived lifecycle state', tag: 'Tasks', responseKey: 'state' },
|
|
55
|
-
{ method: 'get', path: '/tasks/{taskId}/transitions', summary: 'Allowed status transitions', tag: 'Tasks' },
|
|
56
|
-
{ method: 'get', path: '/tasks/{taskId}/ladder', summary: 'Single-task ladder view', tag: 'Analysis', responseKey: 'taskLadder' },
|
|
57
|
-
{ method: 'get', path: '/tasks/{taskId}/metrics', summary: 'Task metrics and EVM', tag: 'Analysis', responseKey: 'metrics' },
|
|
58
|
-
{ method: 'get', path: '/tasks/{taskId}/progress-history', summary: 'Reconstructed progress history', tag: 'Analysis', responseKey: 'progressHistory' },
|
|
59
|
-
|
|
60
|
-
{ method: 'get', path: '/teams', summary: 'List teams', tag: 'Teams', responseKey: 'teams' },
|
|
61
|
-
{ method: 'post', path: '/teams', summary: 'Create a team', tag: 'Teams', body: schemas.CreateTeamSchema, responseKey: 'team', status: 201 },
|
|
62
|
-
{ method: 'get', path: '/teams/{teamId}', summary: 'Get a team', tag: 'Teams', responseKey: 'team' },
|
|
63
|
-
{ method: 'patch', path: '/teams/{teamId}', summary: 'Update a team', tag: 'Teams', body: schemas.UpdateTeamSchema, responseKey: 'team' },
|
|
64
|
-
{ method: 'delete', path: '/teams/{teamId}', summary: 'Delete a team', tag: 'Teams' },
|
|
65
|
-
|
|
66
|
-
{ method: 'get', path: '/containers', summary: 'List containers', tag: 'Containers', query: ['projectId!'], responseKey: 'containers' },
|
|
67
|
-
{ method: 'post', path: '/containers', summary: 'Create a container', tag: 'Containers', body: schemas.CreateContainerSchema, responseKey: 'container', status: 201 },
|
|
68
|
-
{ method: 'get', path: '/containers/{containerId}', summary: 'Get a container', tag: 'Containers', responseKey: 'container' },
|
|
69
|
-
{ method: 'patch', path: '/containers/{containerId}', summary: 'Update a container', tag: 'Containers', body: schemas.UpdateContainerSchema, responseKey: 'container' },
|
|
70
|
-
{ method: 'delete', path: '/containers/{containerId}', summary: 'Delete a container', tag: 'Containers' },
|
|
71
|
-
|
|
72
|
-
{ method: 'get', path: '/deliverables', summary: 'List deliverables', tag: 'Deliverables', query: ['projectId!'], responseKey: 'deliverables' },
|
|
73
|
-
{ method: 'post', path: '/deliverables', summary: 'Create a deliverable', tag: 'Deliverables', body: schemas.CreateDeliverableSchema, responseKey: 'deliverable', status: 201 },
|
|
74
|
-
{ method: 'get', path: '/deliverables/{deliverableId}', summary: 'Get a deliverable', tag: 'Deliverables', responseKey: 'deliverable' },
|
|
75
|
-
{ method: 'get', path: '/deliverables/{deliverableId}/summary', summary: 'Deliverable rollup summary', tag: 'Deliverables', responseKey: 'summary' },
|
|
76
|
-
{ method: 'patch', path: '/deliverables/{deliverableId}', summary: 'Update a deliverable', tag: 'Deliverables', body: schemas.UpdateDeliverableSchema, responseKey: 'deliverable' },
|
|
77
|
-
{ method: 'delete', path: '/deliverables/{deliverableId}', summary: 'Delete a deliverable', tag: 'Deliverables' },
|
|
78
|
-
|
|
79
|
-
{ method: 'get', path: '/iterations', summary: 'List iterations', tag: 'Iterations', query: ['projectId!'], responseKey: 'iterations' },
|
|
80
|
-
{ method: 'post', path: '/iterations', summary: 'Create an iteration', tag: 'Iterations', body: schemas.CreateIterationSchema, responseKey: 'iteration', status: 201 },
|
|
81
|
-
{ method: 'get', path: '/iterations/{iterationId}', summary: 'Get an iteration', tag: 'Iterations', responseKey: 'iteration' },
|
|
82
|
-
{ method: 'patch', path: '/iterations/{iterationId}', summary: 'Update an iteration', tag: 'Iterations', body: schemas.UpdateIterationSchema, responseKey: 'iteration' },
|
|
83
|
-
{ method: 'delete', path: '/iterations/{iterationId}', summary: 'Delete an iteration', tag: 'Iterations' },
|
|
84
|
-
|
|
85
|
-
{ method: 'get', path: '/activities', summary: 'Activity (audit) stream, newest first (follow nextCursor)', tag: 'Activity', query: ['projectId', 'taskId', 'limit', 'cursor'], responseKey: 'activities' },
|
|
86
|
-
|
|
87
|
-
{ method: 'get', path: '/comments', summary: 'List comments for a task', tag: 'Comments', query: ['taskId!'], responseKey: 'comments' },
|
|
88
|
-
{ method: 'post', path: '/comments', summary: 'Create a comment', tag: 'Comments', body: schemas.CreateCommentSchema, responseKey: 'comment', status: 201 },
|
|
89
|
-
{ method: 'get', path: '/comments/{commentId}', summary: 'Get a comment', tag: 'Comments', responseKey: 'comment' },
|
|
90
|
-
{ method: 'patch', path: '/comments/{commentId}', summary: 'Update a comment', tag: 'Comments', body: schemas.UpdateCommentSchema, responseKey: 'comment' },
|
|
91
|
-
{ method: 'delete', path: '/comments/{commentId}', summary: 'Delete a comment', tag: 'Comments' },
|
|
92
|
-
{ method: 'post', path: '/comments/{commentId}/reactions', summary: 'Add a reaction', tag: 'Comments', body: schemas.CommentReactionSchema, responseKey: 'comment' },
|
|
93
|
-
{ method: 'delete', path: '/comments/{commentId}/reactions', summary: 'Remove a reaction', tag: 'Comments', query: ['emoji!'], responseKey: 'comment' },
|
|
94
|
-
|
|
95
|
-
{ method: 'get', path: '/attachments', summary: 'List attachments', tag: 'Attachments', query: ['taskId', 'projectId', 'commentId'], responseKey: 'attachments' },
|
|
96
|
-
{ method: 'post', path: '/attachments', summary: 'Register an attachment', tag: 'Attachments', body: schemas.CreateAttachmentSchema, responseKey: 'attachment', status: 201 },
|
|
97
|
-
{ method: 'post', path: '/attachments/upload', summary: 'Upload a file through the configured FileStorageAdapter', tag: 'Attachments', body: schemas.UploadAttachmentSchema, responseKey: 'attachment', status: 201 },
|
|
98
|
-
{ method: 'post', path: '/attachments/presign', summary: 'Request a presigned upload URL', tag: 'Attachments', body: schemas.PresignAttachmentSchema, responseKey: 'presigned' },
|
|
99
|
-
{ method: 'get', path: '/attachments/{attachmentId}', summary: 'Get an attachment', tag: 'Attachments', responseKey: 'attachment' },
|
|
100
|
-
{ method: 'delete', path: '/attachments/{attachmentId}', summary: 'Delete an attachment', tag: 'Attachments' },
|
|
101
|
-
|
|
102
|
-
{ method: 'get', path: '/time-entries', summary: 'List time entries for a task', tag: 'Time Tracking', query: ['taskId!'], responseKey: 'timeEntries' },
|
|
103
|
-
{ method: 'post', path: '/time-entries', summary: 'Log time against a task', tag: 'Time Tracking', body: schemas.LogTimeSchema, responseKey: 'timeEntry', status: 201 },
|
|
104
|
-
|
|
105
|
-
{ method: 'get', path: '/workload', summary: 'Workload distribution across projects', tag: 'Analysis', query: ['projectId', 'startDate', 'endDate', 'interval', 'groupBy', 'metric', 'defaultWeeklyCapacityHours'], responseKey: 'workload' },
|
|
106
|
-
{ method: 'post', path: '/status', summary: 'Publish an agent status update', tag: 'Agents' }
|
|
107
|
-
];
|
|
108
|
-
|
|
109
|
-
const errorResponse = {
|
|
110
|
-
description: 'Error',
|
|
111
|
-
content: {
|
|
112
|
-
'application/json': {
|
|
113
|
-
schema: {
|
|
114
|
-
type: 'object',
|
|
115
|
-
properties: {
|
|
116
|
-
error: { type: 'string' },
|
|
117
|
-
issues: { type: 'array', items: { type: 'object', properties: { path: { type: 'string' }, message: { type: 'string' } } } }
|
|
118
|
-
},
|
|
119
|
-
required: ['error']
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
function toJsonSchema(schema: unknown): Record<string, unknown> {
|
|
126
|
-
const { $schema: _ignored, ...json } = zodToJsonSchema(schema as any, { $refStrategy: 'none' }) as Record<string, unknown>;
|
|
127
|
-
return json;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export interface OpenApiOptions {
|
|
131
|
-
/** Public URL the API is served from, e.g. `https://app.example.com/api/critical-path`. */
|
|
132
|
-
serverUrl?: string;
|
|
133
|
-
title?: string;
|
|
134
|
-
version?: string;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Builds an OpenAPI 3.1 document for the router. Request bodies are generated from the same zod
|
|
139
|
-
* schemas the router validates with, so the contract cannot drift from enforcement.
|
|
140
|
-
*/
|
|
141
|
-
export function buildOpenApiDocument(options: OpenApiOptions = {}): Record<string, unknown> {
|
|
142
|
-
const paths: Record<string, Record<string, unknown>> = {};
|
|
143
|
-
|
|
144
|
-
for (const route of ROUTES) {
|
|
145
|
-
const pathParams = [...route.path.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
|
|
146
|
-
const parameters = [
|
|
147
|
-
...pathParams.map((name) => ({ name, in: 'path', required: true, schema: { type: 'string' } })),
|
|
148
|
-
...(route.query ?? []).map((q) => ({
|
|
149
|
-
name: q.replace(/!$/, ''),
|
|
150
|
-
in: 'query',
|
|
151
|
-
required: q.endsWith('!'),
|
|
152
|
-
schema: { type: 'string' }
|
|
153
|
-
}))
|
|
154
|
-
];
|
|
155
|
-
const status = String(route.status ?? 200);
|
|
156
|
-
const successSchema = route.responseKey
|
|
157
|
-
? { type: 'object', properties: { [route.responseKey]: {} }, required: [route.responseKey] }
|
|
158
|
-
: { type: 'object' };
|
|
159
|
-
|
|
160
|
-
paths[route.path] ??= {};
|
|
161
|
-
paths[route.path][route.method] = {
|
|
162
|
-
summary: route.summary,
|
|
163
|
-
tags: [route.tag],
|
|
164
|
-
...(parameters.length ? { parameters } : {}),
|
|
165
|
-
...(route.body
|
|
166
|
-
? { requestBody: { required: true, content: { 'application/json': { schema: toJsonSchema(route.body) } } } }
|
|
167
|
-
: {}),
|
|
168
|
-
responses: {
|
|
169
|
-
[status]: { description: 'Success', content: { 'application/json': { schema: successSchema } } },
|
|
170
|
-
'400': errorResponse,
|
|
171
|
-
'401': errorResponse,
|
|
172
|
-
'403': errorResponse,
|
|
173
|
-
'404': errorResponse,
|
|
174
|
-
...(route.path.endsWith('/dependencies') && route.method === 'post' ? { '409': errorResponse } : {}),
|
|
175
|
-
'500': errorResponse
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return {
|
|
181
|
-
openapi: '3.1.0',
|
|
182
|
-
info: { title: options.title ?? 'Critical Path API', version: options.version ?? '1.0.0' },
|
|
183
|
-
...(options.serverUrl ? { servers: [{ url: options.serverUrl }] } : {}),
|
|
184
|
-
paths
|
|
185
|
-
};
|
|
186
|
-
}
|