@mieubrisse/notion-mcp-server 2.0.0
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/.devcontainer/devcontainer.json +4 -0
- package/.dockerignore +3 -0
- package/.github/pull_request_template.md +8 -0
- package/.github/workflows/ci.yml +42 -0
- package/Dockerfile +36 -0
- package/LICENSE +7 -0
- package/README.md +412 -0
- package/docker-compose.yml +6 -0
- package/docs/images/connections.png +0 -0
- package/docs/images/integration-access.png +0 -0
- package/docs/images/integrations-capabilities.png +0 -0
- package/docs/images/integrations-creation.png +0 -0
- package/docs/images/page-access-edit.png +0 -0
- package/package.json +63 -0
- package/scripts/build-cli.js +30 -0
- package/scripts/notion-openapi.json +2238 -0
- package/scripts/start-server.ts +243 -0
- package/src/init-server.ts +50 -0
- package/src/openapi-mcp-server/README.md +3 -0
- package/src/openapi-mcp-server/auth/index.ts +2 -0
- package/src/openapi-mcp-server/auth/template.ts +24 -0
- package/src/openapi-mcp-server/auth/types.ts +26 -0
- package/src/openapi-mcp-server/client/__tests__/http-client-upload.test.ts +205 -0
- package/src/openapi-mcp-server/client/__tests__/http-client.integration.test.ts +282 -0
- package/src/openapi-mcp-server/client/__tests__/http-client.test.ts +537 -0
- package/src/openapi-mcp-server/client/http-client.ts +198 -0
- package/src/openapi-mcp-server/client/polyfill-headers.ts +42 -0
- package/src/openapi-mcp-server/index.ts +3 -0
- package/src/openapi-mcp-server/mcp/__tests__/proxy.test.ts +479 -0
- package/src/openapi-mcp-server/mcp/proxy.ts +250 -0
- package/src/openapi-mcp-server/openapi/__tests__/file-upload.test.ts +100 -0
- package/src/openapi-mcp-server/openapi/__tests__/parser-multipart.test.ts +602 -0
- package/src/openapi-mcp-server/openapi/__tests__/parser.test.ts +1448 -0
- package/src/openapi-mcp-server/openapi/file-upload.ts +40 -0
- package/src/openapi-mcp-server/openapi/parser.ts +529 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'url'
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
4
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
5
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
|
|
6
|
+
import { randomUUID, randomBytes } from 'node:crypto'
|
|
7
|
+
import express from 'express'
|
|
8
|
+
|
|
9
|
+
import { initProxy, ValidationError } from '../src/init-server'
|
|
10
|
+
|
|
11
|
+
export async function startServer(args: string[] = process.argv) {
|
|
12
|
+
const filename = fileURLToPath(import.meta.url)
|
|
13
|
+
const directory = path.dirname(filename)
|
|
14
|
+
const specPath = path.resolve(directory, '../scripts/notion-openapi.json')
|
|
15
|
+
|
|
16
|
+
const baseUrl = process.env.BASE_URL ?? undefined
|
|
17
|
+
|
|
18
|
+
// Parse command line arguments manually (similar to slack-mcp approach)
|
|
19
|
+
function parseArgs() {
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
let transport = 'stdio'; // default
|
|
22
|
+
let port = 3000;
|
|
23
|
+
let authToken: string | undefined;
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < args.length; i++) {
|
|
26
|
+
if (args[i] === '--transport' && i + 1 < args.length) {
|
|
27
|
+
transport = args[i + 1];
|
|
28
|
+
i++; // skip next argument
|
|
29
|
+
} else if (args[i] === '--port' && i + 1 < args.length) {
|
|
30
|
+
port = parseInt(args[i + 1], 10);
|
|
31
|
+
i++; // skip next argument
|
|
32
|
+
} else if (args[i] === '--auth-token' && i + 1 < args.length) {
|
|
33
|
+
authToken = args[i + 1];
|
|
34
|
+
i++; // skip next argument
|
|
35
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
36
|
+
console.log(`
|
|
37
|
+
Usage: notion-mcp-server [options]
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--transport <type> Transport type: 'stdio' or 'http' (default: stdio)
|
|
41
|
+
--port <number> Port for HTTP server when using Streamable HTTP transport (default: 3000)
|
|
42
|
+
--auth-token <token> Bearer token for HTTP transport authentication (optional)
|
|
43
|
+
--help, -h Show this help message
|
|
44
|
+
|
|
45
|
+
Environment Variables:
|
|
46
|
+
NOTION_TOKEN Notion integration token (recommended)
|
|
47
|
+
OPENAPI_MCP_HEADERS JSON string with Notion API headers (alternative)
|
|
48
|
+
AUTH_TOKEN Bearer token for HTTP transport authentication (alternative to --auth-token)
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
notion-mcp-server # Use stdio transport (default)
|
|
52
|
+
notion-mcp-server --transport stdio # Use stdio transport explicitly
|
|
53
|
+
notion-mcp-server --transport http # Use Streamable HTTP transport on port 3000
|
|
54
|
+
notion-mcp-server --transport http --port 8080 # Use Streamable HTTP transport on port 8080
|
|
55
|
+
notion-mcp-server --transport http --auth-token mytoken # Use Streamable HTTP transport with custom auth token
|
|
56
|
+
AUTH_TOKEN=mytoken notion-mcp-server --transport http # Use Streamable HTTP transport with auth token from env var
|
|
57
|
+
`);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
// Ignore unrecognized arguments (like command name passed by Docker)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { transport: transport.toLowerCase(), port, authToken };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const options = parseArgs()
|
|
67
|
+
const transport = options.transport
|
|
68
|
+
|
|
69
|
+
if (transport === 'stdio') {
|
|
70
|
+
// Use stdio transport (default)
|
|
71
|
+
const proxy = await initProxy(specPath, baseUrl)
|
|
72
|
+
await proxy.connect(new StdioServerTransport())
|
|
73
|
+
return proxy.getServer()
|
|
74
|
+
} else if (transport === 'http') {
|
|
75
|
+
// Use Streamable HTTP transport
|
|
76
|
+
const app = express()
|
|
77
|
+
app.use(express.json())
|
|
78
|
+
|
|
79
|
+
// Generate or use provided auth token (from CLI arg or env var)
|
|
80
|
+
const authToken = options.authToken || process.env.AUTH_TOKEN || randomBytes(32).toString('hex')
|
|
81
|
+
if (!options.authToken && !process.env.AUTH_TOKEN) {
|
|
82
|
+
console.log(`Generated auth token: ${authToken}`)
|
|
83
|
+
console.log(`Use this token in the Authorization header: Bearer ${authToken}`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Authorization middleware
|
|
87
|
+
const authenticateToken = (req: express.Request, res: express.Response, next: express.NextFunction): void => {
|
|
88
|
+
const authHeader = req.headers['authorization']
|
|
89
|
+
const token = authHeader && authHeader.split(' ')[1] // Bearer TOKEN
|
|
90
|
+
|
|
91
|
+
if (!token) {
|
|
92
|
+
res.status(401).json({
|
|
93
|
+
jsonrpc: '2.0',
|
|
94
|
+
error: {
|
|
95
|
+
code: -32001,
|
|
96
|
+
message: 'Unauthorized: Missing bearer token',
|
|
97
|
+
},
|
|
98
|
+
id: null,
|
|
99
|
+
})
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (token !== authToken) {
|
|
104
|
+
res.status(403).json({
|
|
105
|
+
jsonrpc: '2.0',
|
|
106
|
+
error: {
|
|
107
|
+
code: -32002,
|
|
108
|
+
message: 'Forbidden: Invalid bearer token',
|
|
109
|
+
},
|
|
110
|
+
id: null,
|
|
111
|
+
})
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
next()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Health endpoint (no authentication required)
|
|
119
|
+
app.get('/health', (req, res) => {
|
|
120
|
+
res.status(200).json({
|
|
121
|
+
status: 'healthy',
|
|
122
|
+
timestamp: new Date().toISOString(),
|
|
123
|
+
transport: 'http',
|
|
124
|
+
port: options.port
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Apply authentication to all /mcp routes
|
|
129
|
+
app.use('/mcp', authenticateToken)
|
|
130
|
+
|
|
131
|
+
// Map to store transports by session ID
|
|
132
|
+
const transports: { [sessionId: string]: StreamableHTTPServerTransport } = {}
|
|
133
|
+
|
|
134
|
+
// Handle POST requests for client-to-server communication
|
|
135
|
+
app.post('/mcp', async (req, res) => {
|
|
136
|
+
try {
|
|
137
|
+
// Check for existing session ID
|
|
138
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
|
139
|
+
let transport: StreamableHTTPServerTransport
|
|
140
|
+
|
|
141
|
+
if (sessionId && transports[sessionId]) {
|
|
142
|
+
// Reuse existing transport
|
|
143
|
+
transport = transports[sessionId]
|
|
144
|
+
} else if (!sessionId && isInitializeRequest(req.body)) {
|
|
145
|
+
// New initialization request
|
|
146
|
+
transport = new StreamableHTTPServerTransport({
|
|
147
|
+
sessionIdGenerator: () => randomUUID(),
|
|
148
|
+
onsessioninitialized: (sessionId) => {
|
|
149
|
+
// Store the transport by session ID
|
|
150
|
+
transports[sessionId] = transport
|
|
151
|
+
}
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// Clean up transport when closed
|
|
155
|
+
transport.onclose = () => {
|
|
156
|
+
if (transport.sessionId) {
|
|
157
|
+
delete transports[transport.sessionId]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const proxy = await initProxy(specPath, baseUrl)
|
|
162
|
+
await proxy.connect(transport)
|
|
163
|
+
} else {
|
|
164
|
+
// Invalid request
|
|
165
|
+
res.status(400).json({
|
|
166
|
+
jsonrpc: '2.0',
|
|
167
|
+
error: {
|
|
168
|
+
code: -32000,
|
|
169
|
+
message: 'Bad Request: No valid session ID provided',
|
|
170
|
+
},
|
|
171
|
+
id: null,
|
|
172
|
+
})
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Handle the request
|
|
177
|
+
await transport.handleRequest(req, res, req.body)
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.error('Error handling MCP request:', error)
|
|
180
|
+
if (!res.headersSent) {
|
|
181
|
+
res.status(500).json({
|
|
182
|
+
jsonrpc: '2.0',
|
|
183
|
+
error: {
|
|
184
|
+
code: -32603,
|
|
185
|
+
message: 'Internal server error',
|
|
186
|
+
},
|
|
187
|
+
id: null,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Handle GET requests for server-to-client notifications via Streamable HTTP
|
|
194
|
+
app.get('/mcp', async (req, res) => {
|
|
195
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
|
196
|
+
if (!sessionId || !transports[sessionId]) {
|
|
197
|
+
res.status(400).send('Invalid or missing session ID')
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const transport = transports[sessionId]
|
|
202
|
+
await transport.handleRequest(req, res)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
// Handle DELETE requests for session termination
|
|
206
|
+
app.delete('/mcp', async (req, res) => {
|
|
207
|
+
const sessionId = req.headers['mcp-session-id'] as string | undefined
|
|
208
|
+
if (!sessionId || !transports[sessionId]) {
|
|
209
|
+
res.status(400).send('Invalid or missing session ID')
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const transport = transports[sessionId]
|
|
214
|
+
await transport.handleRequest(req, res)
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const port = options.port
|
|
218
|
+
app.listen(port, '0.0.0.0', () => {
|
|
219
|
+
console.log(`MCP Server listening on port ${port}`)
|
|
220
|
+
console.log(`Endpoint: http://0.0.0.0:${port}/mcp`)
|
|
221
|
+
console.log(`Health check: http://0.0.0.0:${port}/health`)
|
|
222
|
+
console.log(`Authentication: Bearer token required`)
|
|
223
|
+
if (options.authToken) {
|
|
224
|
+
console.log(`Using provided auth token`)
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
// Return a dummy server for compatibility
|
|
229
|
+
return { close: () => {} }
|
|
230
|
+
} else {
|
|
231
|
+
throw new Error(`Unsupported transport: ${transport}. Use 'stdio' or 'http'.`)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
startServer(process.argv).catch(error => {
|
|
236
|
+
if (error instanceof ValidationError) {
|
|
237
|
+
console.error('Invalid OpenAPI 3.1 specification:')
|
|
238
|
+
error.errors.forEach(err => console.error(err))
|
|
239
|
+
} else {
|
|
240
|
+
console.error('Error:', error)
|
|
241
|
+
}
|
|
242
|
+
process.exit(1)
|
|
243
|
+
})
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { OpenAPIV3 } from 'openapi-types'
|
|
5
|
+
import OpenAPISchemaValidator from 'openapi-schema-validator'
|
|
6
|
+
|
|
7
|
+
import { MCPProxy } from './openapi-mcp-server/mcp/proxy'
|
|
8
|
+
|
|
9
|
+
export class ValidationError extends Error {
|
|
10
|
+
constructor(public errors: any[]) {
|
|
11
|
+
super('OpenAPI validation failed')
|
|
12
|
+
this.name = 'ValidationError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function loadOpenApiSpec(specPath: string, baseUrl: string | undefined): Promise<OpenAPIV3.Document> {
|
|
17
|
+
let rawSpec: string
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
rawSpec = fs.readFileSync(path.resolve(process.cwd(), specPath), 'utf-8')
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error('Failed to read OpenAPI specification file:', (error as Error).message)
|
|
23
|
+
process.exit(1)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Parse and validate the OpenApi Spec
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(rawSpec)
|
|
29
|
+
|
|
30
|
+
// Override baseUrl if specified.
|
|
31
|
+
if (baseUrl) {
|
|
32
|
+
parsed.servers[0].url = baseUrl
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return parsed as OpenAPIV3.Document
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof ValidationError) {
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
console.error('Failed to parse OpenAPI spec:', (error as Error).message)
|
|
41
|
+
process.exit(1)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function initProxy(specPath: string, baseUrl: string |undefined) {
|
|
46
|
+
const openApiSpec = await loadOpenApiSpec(specPath, baseUrl)
|
|
47
|
+
const proxy = new MCPProxy('Notion API', openApiSpec)
|
|
48
|
+
|
|
49
|
+
return proxy
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import Mustache from 'mustache'
|
|
2
|
+
import { AuthTemplate, TemplateContext } from './types'
|
|
3
|
+
|
|
4
|
+
export function renderAuthTemplate(template: AuthTemplate, context: TemplateContext): AuthTemplate {
|
|
5
|
+
// Disable HTML escaping for URLs
|
|
6
|
+
Mustache.escape = (text) => text
|
|
7
|
+
|
|
8
|
+
// Render URL with template variables
|
|
9
|
+
const renderedUrl = Mustache.render(template.url, context)
|
|
10
|
+
|
|
11
|
+
// Create a new template object with rendered values
|
|
12
|
+
const renderedTemplate: AuthTemplate = {
|
|
13
|
+
...template,
|
|
14
|
+
url: renderedUrl,
|
|
15
|
+
headers: { ...template.headers }, // Create a new headers object to avoid modifying the original
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Render body if it exists
|
|
19
|
+
if (template.body) {
|
|
20
|
+
renderedTemplate.body = Mustache.render(template.body, context)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return renderedTemplate
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
|
2
|
+
|
|
3
|
+
export interface AuthTemplate {
|
|
4
|
+
url: string
|
|
5
|
+
method: HttpMethod
|
|
6
|
+
headers: Record<string, string>
|
|
7
|
+
body?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SecurityScheme {
|
|
11
|
+
[key: string]: {
|
|
12
|
+
tokenUrl?: string
|
|
13
|
+
[key: string]: any
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Server {
|
|
18
|
+
url: string
|
|
19
|
+
description?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TemplateContext {
|
|
23
|
+
securityScheme?: SecurityScheme
|
|
24
|
+
servers?: Server[]
|
|
25
|
+
args: Record<string, string>
|
|
26
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { HttpClient } from '../http-client'
|
|
3
|
+
import { OpenAPIV3 } from 'openapi-types'
|
|
4
|
+
import fs from 'fs'
|
|
5
|
+
import FormData from 'form-data'
|
|
6
|
+
|
|
7
|
+
vi.mock('fs')
|
|
8
|
+
vi.mock('form-data')
|
|
9
|
+
|
|
10
|
+
describe('HttpClient File Upload', () => {
|
|
11
|
+
let client: HttpClient
|
|
12
|
+
const mockApiInstance = {
|
|
13
|
+
uploadFile: vi.fn(),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const baseConfig = {
|
|
17
|
+
baseUrl: 'http://test.com',
|
|
18
|
+
headers: {},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const mockOpenApiSpec: OpenAPIV3.Document = {
|
|
22
|
+
openapi: '3.0.0',
|
|
23
|
+
info: {
|
|
24
|
+
title: 'Test API',
|
|
25
|
+
version: '1.0.0',
|
|
26
|
+
},
|
|
27
|
+
paths: {
|
|
28
|
+
'/upload': {
|
|
29
|
+
post: {
|
|
30
|
+
operationId: 'uploadFile',
|
|
31
|
+
responses: {
|
|
32
|
+
'200': {
|
|
33
|
+
description: 'File uploaded successfully',
|
|
34
|
+
content: {
|
|
35
|
+
'application/json': {
|
|
36
|
+
schema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
success: {
|
|
40
|
+
type: 'boolean',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
requestBody: {
|
|
49
|
+
content: {
|
|
50
|
+
'multipart/form-data': {
|
|
51
|
+
schema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
file: {
|
|
55
|
+
type: 'string',
|
|
56
|
+
format: 'binary',
|
|
57
|
+
},
|
|
58
|
+
description: {
|
|
59
|
+
type: 'string',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
vi.clearAllMocks()
|
|
73
|
+
client = new HttpClient(baseConfig, mockOpenApiSpec)
|
|
74
|
+
// @ts-expect-error - Mock the private api property
|
|
75
|
+
client['api'] = Promise.resolve(mockApiInstance)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should handle file uploads with FormData', async () => {
|
|
79
|
+
const mockFormData = new FormData()
|
|
80
|
+
const mockFileStream = { pipe: vi.fn() }
|
|
81
|
+
const mockFormDataHeaders = { 'content-type': 'multipart/form-data; boundary=---123' }
|
|
82
|
+
|
|
83
|
+
vi.mocked(fs.createReadStream).mockReturnValue(mockFileStream as any)
|
|
84
|
+
vi.spyOn(FormData.prototype, 'append').mockImplementation(() => {})
|
|
85
|
+
vi.spyOn(FormData.prototype, 'getHeaders').mockReturnValue(mockFormDataHeaders)
|
|
86
|
+
|
|
87
|
+
const uploadPath = mockOpenApiSpec.paths['/upload']
|
|
88
|
+
if (!uploadPath?.post) {
|
|
89
|
+
throw new Error('Upload path not found in spec')
|
|
90
|
+
}
|
|
91
|
+
const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string }
|
|
92
|
+
const params = {
|
|
93
|
+
file: '/path/to/test.txt',
|
|
94
|
+
description: 'Test file',
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
mockApiInstance.uploadFile.mockResolvedValue({
|
|
98
|
+
data: { success: true },
|
|
99
|
+
status: 200,
|
|
100
|
+
headers: {},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await client.executeOperation(operation, params)
|
|
104
|
+
|
|
105
|
+
expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test.txt')
|
|
106
|
+
expect(FormData.prototype.append).toHaveBeenCalledWith('file', mockFileStream)
|
|
107
|
+
expect(FormData.prototype.append).toHaveBeenCalledWith('description', 'Test file')
|
|
108
|
+
expect(mockApiInstance.uploadFile).toHaveBeenCalledWith({}, expect.any(FormData), { headers: mockFormDataHeaders })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should throw error for invalid file path', async () => {
|
|
112
|
+
vi.mocked(fs.createReadStream).mockImplementation(() => {
|
|
113
|
+
throw new Error('File not found')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const uploadPath = mockOpenApiSpec.paths['/upload']
|
|
117
|
+
if (!uploadPath?.post) {
|
|
118
|
+
throw new Error('Upload path not found in spec')
|
|
119
|
+
}
|
|
120
|
+
const operation = uploadPath.post as OpenAPIV3.OperationObject & { method: string; path: string }
|
|
121
|
+
const params = {
|
|
122
|
+
file: '/nonexistent/file.txt',
|
|
123
|
+
description: 'Test file',
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
await expect(client.executeOperation(operation, params)).rejects.toThrow('Failed to read file at /nonexistent/file.txt')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('should handle multiple file uploads', async () => {
|
|
130
|
+
const mockFormData = new FormData()
|
|
131
|
+
const mockFileStream1 = { pipe: vi.fn() }
|
|
132
|
+
const mockFileStream2 = { pipe: vi.fn() }
|
|
133
|
+
const mockFormDataHeaders = { 'content-type': 'multipart/form-data; boundary=---123' }
|
|
134
|
+
|
|
135
|
+
vi.mocked(fs.createReadStream)
|
|
136
|
+
.mockReturnValueOnce(mockFileStream1 as any)
|
|
137
|
+
.mockReturnValueOnce(mockFileStream2 as any)
|
|
138
|
+
vi.spyOn(FormData.prototype, 'append').mockImplementation(() => {})
|
|
139
|
+
vi.spyOn(FormData.prototype, 'getHeaders').mockReturnValue(mockFormDataHeaders)
|
|
140
|
+
|
|
141
|
+
const operation: OpenAPIV3.OperationObject = {
|
|
142
|
+
operationId: 'uploadFile',
|
|
143
|
+
responses: {
|
|
144
|
+
'200': {
|
|
145
|
+
description: 'Files uploaded successfully',
|
|
146
|
+
content: {
|
|
147
|
+
'application/json': {
|
|
148
|
+
schema: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
properties: {
|
|
151
|
+
success: {
|
|
152
|
+
type: 'boolean',
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
requestBody: {
|
|
161
|
+
content: {
|
|
162
|
+
'multipart/form-data': {
|
|
163
|
+
schema: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
properties: {
|
|
166
|
+
file1: {
|
|
167
|
+
type: 'string',
|
|
168
|
+
format: 'binary',
|
|
169
|
+
},
|
|
170
|
+
file2: {
|
|
171
|
+
type: 'string',
|
|
172
|
+
format: 'binary',
|
|
173
|
+
},
|
|
174
|
+
description: {
|
|
175
|
+
type: 'string',
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const params = {
|
|
185
|
+
file1: '/path/to/test1.txt',
|
|
186
|
+
file2: '/path/to/test2.txt',
|
|
187
|
+
description: 'Test files',
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
mockApiInstance.uploadFile.mockResolvedValue({
|
|
191
|
+
data: { success: true },
|
|
192
|
+
status: 200,
|
|
193
|
+
headers: {},
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
await client.executeOperation(operation as OpenAPIV3.OperationObject & { method: string; path: string }, params)
|
|
197
|
+
|
|
198
|
+
expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test1.txt')
|
|
199
|
+
expect(fs.createReadStream).toHaveBeenCalledWith('/path/to/test2.txt')
|
|
200
|
+
expect(FormData.prototype.append).toHaveBeenCalledWith('file1', mockFileStream1)
|
|
201
|
+
expect(FormData.prototype.append).toHaveBeenCalledWith('file2', mockFileStream2)
|
|
202
|
+
expect(FormData.prototype.append).toHaveBeenCalledWith('description', 'Test files')
|
|
203
|
+
expect(mockApiInstance.uploadFile).toHaveBeenCalledWith({}, expect.any(FormData), { headers: mockFormDataHeaders })
|
|
204
|
+
})
|
|
205
|
+
})
|