From ce0e4f2589437eaddcdd4c8c3f8822e4c5026740 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 30 Jun 2026 17:19:11 +0800 Subject: [PATCH] feat(server-v2): expose /openapi.json via @fastify/swagger - register @fastify/swagger before routes and serve GET /openapi.json - add v2-specific openapi transform for multipart upload, binary downloads, and the fs-action/question oneOf dispatchers - project the session-action dispatcher into archive only (v2 registers a subset of v1 routes) - reuse protocol wire schemas, no inline re-declaration --- packages/server-v2/package.json | 1 + packages/server-v2/src/openapi/transforms.ts | 341 +++++++++++++++++++ packages/server-v2/src/start.ts | 51 ++- packages/server-v2/test/openapi.test.ts | 119 +++++++ pnpm-lock.yaml | 3 + 5 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 packages/server-v2/src/openapi/transforms.ts create mode 100644 packages/server-v2/test/openapi.test.ts diff --git a/packages/server-v2/package.json b/packages/server-v2/package.json index 6bf363750..26b6a25fa 100644 --- a/packages/server-v2/package.json +++ b/packages/server-v2/package.json @@ -21,6 +21,7 @@ "@moonshot-ai/agent-core-v2": "workspace:^", "@moonshot-ai/protocol": "workspace:^", "@fastify/multipart": "^10.0.0", + "@fastify/swagger": "^9.7.0", "fastify": "^5.1.0", "node-pty": "^1.1.0", "pino": "^9.5.0", diff --git a/packages/server-v2/src/openapi/transforms.ts b/packages/server-v2/src/openapi/transforms.ts new file mode 100644 index 000000000..63264042e --- /dev/null +++ b/packages/server-v2/src/openapi/transforms.ts @@ -0,0 +1,341 @@ +/** + * `openapi` transforms — post-process the `@fastify/swagger` document so the + * routes that swagger cannot describe from static schemas (multipart upload, + * binary download, and the `{tail}` dispatchers) are represented accurately. + * + * Tailored to the route subset `server-v2` actually registers. Notably, the + * session-action dispatcher only serves `::archive` in v2, so this module + * projects `/sessions/{tail}` into a single `/sessions/{session_id}:archive` + * operation — unlike v1, which clones it into fork / compact / undo. Wire + * schemas are re-used from `@moonshot-ai/protocol`; none are re-declared here. + */ + +import { + archiveSessionResponseSchema, + fsDiffRequestSchema, + fsDiffResponseSchema, + fsGitStatusRequestSchema, + fsGitStatusResponseSchema, + fsGrepRequestSchema, + fsGrepResponseSchema, + fsListManyRequestSchema, + fsListManyResponseSchema, + fsListRequestSchema, + fsListResponseSchema, + fsMkdirRequestSchema, + fsMkdirResponseSchema, + fsOpenInRequestSchema, + fsOpenInResponseSchema, + fsOpenRequestSchema, + fsOpenResponseSchema, + fsReadRequestSchema, + fsReadResponseSchema, + fsRevealRequestSchema, + fsRevealResponseSchema, + fsSearchRequestSchema, + fsSearchResponseSchema, + fsStatManyRequestSchema, + fsStatManyResponseSchema, + fsStatRequestSchema, + fsStatResponseSchema, + questionDismissResultSchema, + questionResolveRequestSchema, + questionResolveResultSchema, +} from '@moonshot-ai/protocol'; +import { z } from 'zod'; + +import { + openApiDocumentEnvelopeJsonSchema, + openApiDocumentJsonSchema, +} from '../middleware/schema'; + +const binarySchema = { + type: 'string', + format: 'binary', +} as const; + +const fileUploadMultipartSchema = { + type: 'object', + properties: { + file: binarySchema, + name: { type: 'string' }, + expires_in_sec: { type: 'number', minimum: 0 }, + }, + required: ['file'], +} as const; + +const errorEnvelopeSchema = openApiDocumentEnvelopeJsonSchema(z.null()); + +const fsActionRequestSchema = { + oneOf: [ + openApiDocumentJsonSchema(fsListRequestSchema), + openApiDocumentJsonSchema(fsReadRequestSchema), + openApiDocumentJsonSchema(fsListManyRequestSchema), + openApiDocumentJsonSchema(fsStatRequestSchema), + openApiDocumentJsonSchema(fsStatManyRequestSchema), + openApiDocumentJsonSchema(fsMkdirRequestSchema), + openApiDocumentJsonSchema(fsSearchRequestSchema), + openApiDocumentJsonSchema(fsGrepRequestSchema), + openApiDocumentJsonSchema(fsGitStatusRequestSchema), + openApiDocumentJsonSchema(fsDiffRequestSchema), + openApiDocumentJsonSchema(fsOpenRequestSchema), + openApiDocumentJsonSchema(fsOpenInRequestSchema), + openApiDocumentJsonSchema(fsRevealRequestSchema), + ], +} as const; + +const fsActionResponseSchema = { + oneOf: [ + openApiDocumentEnvelopeJsonSchema(fsListResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsReadResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsListManyResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsStatResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsStatManyResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsMkdirResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsSearchResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsGrepResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsGitStatusResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsDiffResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsOpenResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsOpenInResponseSchema), + openApiDocumentEnvelopeJsonSchema(fsRevealResponseSchema), + ], +} as const; + +const questionResponseSchema = { + oneOf: [ + openApiDocumentEnvelopeJsonSchema(questionResolveResultSchema), + openApiDocumentEnvelopeJsonSchema(questionDismissResultSchema), + ], +} as const; + +export function transformOpenApiDocument( + document: Record, +): Record { + const paths = asRecord(document['paths']); + if (paths === undefined) return document; + + patchFileUpload(paths); + patchFileDownload(paths); + patchSessionAction(paths); + patchFsAction(paths); + patchFsDownload(paths); + patchQuestionResolveOrDismiss(paths); + + return document; +} + +function patchFileUpload(paths: Record): void { + const operation = getOperation(paths, '/api/v1/files', 'post'); + if (operation === undefined) return; + + operation['requestBody'] = { + required: true, + content: { + 'multipart/form-data': { + schema: fileUploadMultipartSchema, + }, + }, + }; +} + +function patchFileDownload(paths: Record): void { + const operation = getOperation(paths, '/api/v1/files/{file_id}', 'get'); + if (operation === undefined) return; + + setResponse(operation, '200', { + description: 'Binary file download', + headers: { + 'content-disposition': headerString(), + 'content-length': headerInteger(), + etag: headerString(), + }, + content: { + 'application/octet-stream': { + schema: binarySchema, + }, + }, + }); + setResponse(operation, '404', { + description: 'File not found', + content: jsonContent(errorEnvelopeSchema), + }); +} + +function patchSessionAction(paths: Record): void { + const internalPath = '/api/v1/sessions/{tail}'; + const pathItem = asRecord(paths[internalPath]); + const operation = asRecord(pathItem?.['post']); + if (pathItem === undefined || operation === undefined) return; + + const cloned = cloneRecord(pathItem); + replacePathParamName(cloned, 'tail', 'session_id'); + const clonedOperation = asRecord(cloned['post']); + if (clonedOperation !== undefined) { + clonedOperation['operationId'] = 'runSessionArchiveAction'; + setResponse(clonedOperation, '200', { + description: 'Session archive response', + content: jsonContent(openApiDocumentEnvelopeJsonSchema(archiveSessionResponseSchema)), + }); + } + paths['/api/v1/sessions/{session_id}:archive'] = cloned; + delete paths[internalPath]; +} + +function patchFsAction(paths: Record): void { + const operation = getOperation(paths, '/api/v1/sessions/{session_id}/{tail}', 'post'); + if (operation === undefined) return; + + operation['description'] = appendDescription( + operation['description'], + 'The request and response schemas depend on the `fs:` path tail and are represented as OpenAPI `oneOf` unions.', + ); + operation['requestBody'] = { + required: true, + content: jsonContent(fsActionRequestSchema), + }; + setResponse(operation, '200', { + description: 'Filesystem action response', + content: jsonContent(fsActionResponseSchema), + }); +} + +function patchFsDownload(paths: Record): void { + const operation = getOperation(paths, '/api/v1/sessions/{session_id}/fs/{*}', 'get'); + if (operation === undefined) return; + + setResponse(operation, '200', { + description: 'Binary workspace file download', + headers: { + 'content-disposition': headerString(), + 'content-length': headerInteger(), + etag: headerString(), + 'last-modified': headerString(), + }, + content: { + 'application/octet-stream': { + schema: binarySchema, + }, + }, + }); + setResponse(operation, '206', { + description: 'Partial binary workspace file download', + headers: { + 'content-disposition': headerString(), + 'content-length': headerInteger(), + 'content-range': headerString(), + etag: headerString(), + 'last-modified': headerString(), + }, + content: { + 'application/octet-stream': { + schema: binarySchema, + }, + }, + }); + setResponse(operation, '304', { + description: 'Not modified', + headers: { + etag: headerString(), + }, + }); +} + +function patchQuestionResolveOrDismiss(paths: Record): void { + const operation = getOperation(paths, '/api/v1/sessions/{session_id}/questions/{tail}', 'post'); + if (operation === undefined) return; + + operation['description'] = appendDescription( + operation['description'], + 'Resolve uses the question response body; `:dismiss` sends an empty body.', + ); + operation['requestBody'] = { + required: false, + content: jsonContent(openApiDocumentJsonSchema(questionResolveRequestSchema)), + }; + setResponse(operation, '200', { + description: 'Question resolved or dismissed', + content: jsonContent(questionResponseSchema), + }); +} + +function getOperation( + paths: Record, + path: string, + method: string, +): Record | undefined { + const pathItem = asRecord(paths[path]); + if (pathItem === undefined) return undefined; + return asRecord(pathItem[method]); +} + +function setResponse( + operation: Record, + statusCode: string, + response: Record, +): void { + const responses = asRecord(operation['responses']) ?? {}; + responses[statusCode] = response; + operation['responses'] = responses; +} + +function jsonContent(schema: Record): Record { + return { + 'application/json': { + schema, + }, + }; +} + +function headerString(): Record { + return { + schema: { + type: 'string', + }, + }; +} + +function headerInteger(): Record { + return { + schema: { + type: 'integer', + }, + }; +} + +function appendDescription(existing: unknown, extra: string): string { + if (typeof existing !== 'string' || existing.length === 0) return extra; + return `${existing} ${extra}`; +} + +function replacePathParamName( + container: Record, + from: string, + to: string, +): void { + const params = container['parameters']; + if (Array.isArray(params)) { + for (const param of params) { + const record = asRecord(param); + if (record?.['in'] === 'path' && record['name'] === from) { + record['name'] = to; + } + } + } + + for (const method of ['get', 'post', 'put', 'patch', 'delete']) { + const operation = asRecord(container[method]); + if (operation !== undefined) { + replacePathParamName(operation, from, to); + } + } +} + +function cloneRecord(value: Record): Record { + return structuredClone(value); +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value !== 'object' || value === null) return undefined; + return value as Record; +} diff --git a/packages/server-v2/src/start.ts b/packages/server-v2/src/start.ts index f7e65daa0..b20d6779e 100644 --- a/packages/server-v2/src/start.ts +++ b/packages/server-v2/src/start.ts @@ -19,6 +19,7 @@ import { import Fastify, { type FastifyInstance } from 'fastify'; import { installErrorHandler } from './error-handler'; +import { transformOpenApiDocument } from './openapi/transforms'; import { resolveRequestId } from './request-id'; import { registerApiV1Routes } from './routes/registerApiV1Routes'; // Registers the real `node-pty` `ITerminalBackend`, overriding the @@ -92,8 +93,51 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { + const { default: swagger } = await import('@fastify/swagger'); + await app.register(swagger, { + openapi: { + info: { + title: 'Kimi Code Server API', + description: + 'REST API for the Kimi Code local server. All JSON responses are wrapped in a uniform envelope `{ code, msg, data, request_id }`.', + version: serverVersion, + }, + tags: [ + { name: 'meta', description: 'Server metadata' }, + { name: 'auth', description: 'Auth readiness & login state' }, + { name: 'models', description: 'Configured model aliases' }, + { name: 'providers', description: 'Configured providers' }, + { name: 'sessions', description: 'Session lifecycle' }, + { name: 'workspaces', description: 'Workspace registry + folder picker' }, + { name: 'messages', description: 'Message history' }, + { name: 'prompts', description: 'Prompt submission & abort' }, + { name: 'approvals', description: 'Approval resolution' }, + { name: 'questions', description: 'Question resolution & dismiss' }, + { name: 'tools', description: 'Tool & MCP server management' }, + { name: 'tasks', description: 'Background tasks' }, + { name: 'terminals', description: 'PTY terminal sessions' }, + { name: 'fs', description: 'Filesystem operations' }, + { name: 'files', description: 'File upload & download' }, + ], + }, + transformObject: (documentObject) => { + if (!('openapiObject' in documentObject)) { + return documentObject.swaggerObject; + } + return transformOpenApiDocument(documentObject.openapiObject as Record); + }, + }); + } + + // `@fastify/swagger` collects route schemas via an `onRoute` hook, so it must + // be registered before any routes it should document. + await registerOpenApi(); + await registerApiV1Routes(app, core, { - serverVersion: getServerVersion(), + serverVersion, debugEndpoints: opts.debugEndpoints, onShutdown: () => { void close(); @@ -103,6 +147,11 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { + const openApiDocument = (app as unknown as { swagger(): unknown }).swagger(); + return reply.type('application/json').send(openApiDocument); + }); + const host = opts.host ?? DEFAULT_HOST; const port = opts.port ?? DEFAULT_PORT; await app.listen({ host, port }); diff --git a/packages/server-v2/test/openapi.test.ts b/packages/server-v2/test/openapi.test.ts new file mode 100644 index 000000000..416dadb00 --- /dev/null +++ b/packages/server-v2/test/openapi.test.ts @@ -0,0 +1,119 @@ +/** + * OpenAPI smoke test for server-v2. + * + * Boots the server, fetches `/openapi.json`, and asserts that `@fastify/swagger` + * is wired and that the v2-specific post-processing transforms ran (as opposed + * to a verbatim copy of v1's transforms, which would fabricate endpoints v2 + * does not register). + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { type RunningServer, startServer } from '../src/start'; + +describe('server-v2 OpenAPI', () => { + let server: RunningServer | undefined; + let home: string | undefined; + + afterEach(async () => { + if (server !== undefined) { + await server.close(); + server = undefined; + } + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function fetchOpenApi(): Promise> { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-openapi-')); + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + }); + const res = await fetch(`http://127.0.0.1:${server.port}/openapi.json`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + return (await res.json()) as Record; + } + + it('returns a valid OpenAPI 3 document', async () => { + const doc = await fetchOpenApi(); + + expect(doc['openapi']).toMatch(/^3\.\d+\.\d+$/); + const info = asRecord(doc['info']); + expect(info['title']).toBe('Kimi Code Server API'); + expect(typeof info['version']).toBe('string'); + }); + + it('covers the core /api/v1 routes v2 registers', async () => { + const doc = await fetchOpenApi(); + const paths = asRecord(doc['paths']); + + expect(paths['/api/v1/healthz']).toBeDefined(); + expect(paths['/api/v1/meta']).toBeDefined(); + expect(paths['/api/v1/sessions']).toBeDefined(); + expect(paths['/api/v1/files']).toBeDefined(); + expect(paths['/api/v1/sessions/{session_id}/fs/{*}']).toBeDefined(); + }); + + it('projects the session-action dispatcher into archive only', async () => { + const doc = await fetchOpenApi(); + const paths = asRecord(doc['paths']); + + // v2 only registers ::archive — the generic `{tail}` path must be gone and + // the v1-only actions must not be fabricated. + expect(paths['/api/v1/sessions/{tail}']).toBeUndefined(); + expect(paths['/api/v1/sessions/{session_id}:archive']).toBeDefined(); + expect(paths['/api/v1/sessions/{session_id}:fork']).toBeUndefined(); + expect(paths['/api/v1/sessions/{session_id}:undo']).toBeUndefined(); + + const archiveOp = operation(doc, '/api/v1/sessions/{session_id}:archive', 'post'); + expect(archiveOp['operationId']).toBe('runSessionArchiveAction'); + const params = archiveOp['parameters'] as Array>; + expect(params.some((p) => p['in'] === 'path' && p['name'] === 'session_id')).toBe(true); + expect(params.some((p) => p['name'] === 'tail')).toBe(false); + }); + + it('describes the file upload as multipart/form-data', async () => { + const doc = await fetchOpenApi(); + const uploadOp = operation(doc, '/api/v1/files', 'post'); + const requestBody = asRecord(uploadOp['requestBody']); + const content = asRecord(requestBody['content']); + expect(content['multipart/form-data']).toBeDefined(); + }); + + it('represents the fs-action dispatcher as a oneOf union', async () => { + const doc = await fetchOpenApi(); + const fsActionOp = operation(doc, '/api/v1/sessions/{session_id}/{tail}', 'post'); + const requestBody = asRecord(fsActionOp['requestBody']); + const content = asRecord(requestBody['content']); + const json = asRecord(content['application/json']); + const schema = asRecord(json['schema']); + expect(Array.isArray(schema['oneOf'])).toBe(true); + }); +}); + +function asRecord(value: unknown): Record { + if (typeof value !== 'object' || value === null) { + throw new Error('expected object'); + } + return value as Record; +} + +function operation( + doc: Record, + path: string, + method: string, +): Record { + const paths = asRecord(doc['paths']); + const pathItem = asRecord(paths[path]); + return asRecord(pathItem[method]); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52a7872c8..8f4d81976 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -675,6 +675,9 @@ importers: '@fastify/multipart': specifier: ^10.0.0 version: 10.0.0 + '@fastify/swagger': + specifier: ^9.7.0 + version: 9.7.0 '@moonshot-ai/agent-core-v2': specifier: workspace:^ version: link:../agent-core-v2