mpx-api 1.1.0 → 1.2.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/bin/mpx-api.js +4 -3
- package/package.json +1 -1
- package/src/commands/collection.js +14 -7
- package/src/commands/request.js +3 -2
- package/src/lib/assertion.js +3 -21
- package/src/lib/http-client.js +25 -3
- package/src/lib/template.js +3 -22
- package/src/lib/utils.js +25 -0
- package/src/schema.js +28 -41
package/bin/mpx-api.js
CHANGED
|
@@ -36,9 +36,10 @@ program
|
|
|
36
36
|
.name('mpx-api')
|
|
37
37
|
.description('Developer-first API testing, mocking, and documentation CLI')
|
|
38
38
|
.version(pkg.version)
|
|
39
|
-
.
|
|
40
|
-
.
|
|
41
|
-
.option('--
|
|
39
|
+
.enablePositionalOptions()
|
|
40
|
+
.passThroughOptions()
|
|
41
|
+
.option('-o, --output <format>', 'Output format: json for structured JSON (machine-readable)')
|
|
42
|
+
.option('-q, --quiet', 'Suppress non-essential output');
|
|
42
43
|
|
|
43
44
|
// Register HTTP method commands (get, post, put, patch, delete, head, options)
|
|
44
45
|
registerRequestCommands(program);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
2
|
import { parse, stringify } from 'yaml';
|
|
3
3
|
import { ensureLocalDir } from '../lib/config.js';
|
|
4
|
-
import { formatSuccess, formatError, formatInfo } from '../lib/output.js';
|
|
4
|
+
import { formatSuccess, formatError, formatInfo, formatWarning } from '../lib/output.js';
|
|
5
5
|
import { runCollection } from '../lib/collection-runner.js';
|
|
6
6
|
import { formatTestResults } from '../lib/output.js';
|
|
7
7
|
import { join } from 'path';
|
|
@@ -95,7 +95,8 @@ export function registerCollectionCommands(program) {
|
|
|
95
95
|
.description('Run a collection')
|
|
96
96
|
.option('-e, --env <name>', 'Environment to use')
|
|
97
97
|
.option('--base-url <url>', 'Override base URL')
|
|
98
|
-
.
|
|
98
|
+
.option('--json', 'Output results as JSON')
|
|
99
|
+
.action(async (file, options, command) => {
|
|
99
100
|
try {
|
|
100
101
|
const collectionPath = file || join('.mpx-api', 'collection.yaml');
|
|
101
102
|
|
|
@@ -123,9 +124,17 @@ export function registerCollectionCommands(program) {
|
|
|
123
124
|
|
|
124
125
|
const results = await runCollection(collection, { env, baseUrl });
|
|
125
126
|
|
|
126
|
-
const
|
|
127
|
+
const globalOpts = command.optsWithGlobals();
|
|
128
|
+
const jsonOutput = options.json || globalOpts.output === 'json';
|
|
127
129
|
|
|
128
|
-
|
|
130
|
+
if (jsonOutput) {
|
|
131
|
+
console.log(JSON.stringify(results, null, 2));
|
|
132
|
+
const allPassed = results.every(r => r.passed);
|
|
133
|
+
process.exit(allPassed ? 0 : 1);
|
|
134
|
+
} else {
|
|
135
|
+
const allPassed = formatTestResults(results);
|
|
136
|
+
process.exit(allPassed ? 0 : 1);
|
|
137
|
+
}
|
|
129
138
|
} catch (err) {
|
|
130
139
|
formatError(err);
|
|
131
140
|
process.exit(1);
|
|
@@ -170,6 +179,4 @@ export function registerCollectionCommands(program) {
|
|
|
170
179
|
});
|
|
171
180
|
}
|
|
172
181
|
|
|
173
|
-
|
|
174
|
-
console.log(chalk.yellow('⚠'), message);
|
|
175
|
-
}
|
|
182
|
+
// formatWarning imported from output.js
|
package/src/commands/request.js
CHANGED
|
@@ -17,11 +17,12 @@ export function registerRequestCommands(program) {
|
|
|
17
17
|
.option('--no-follow', 'Do not follow redirects')
|
|
18
18
|
.option('--no-verify', 'Skip SSL certificate verification')
|
|
19
19
|
.option('--timeout <ms>', 'Request timeout in milliseconds', '30000')
|
|
20
|
+
.option('-o, --output <format>', 'Output format (json)')
|
|
20
21
|
.action(async (url, options, command) => {
|
|
21
22
|
try {
|
|
22
23
|
// Get global options
|
|
23
24
|
const globalOpts = command.optsWithGlobals();
|
|
24
|
-
const jsonOutput =
|
|
25
|
+
const jsonOutput = options.output === 'json' || globalOpts.output === 'json';
|
|
25
26
|
const quiet = options.quiet || globalOpts.quiet || false;
|
|
26
27
|
|
|
27
28
|
const client = new HttpClient({
|
|
@@ -82,7 +83,7 @@ export function registerRequestCommands(program) {
|
|
|
82
83
|
}
|
|
83
84
|
} catch (err) {
|
|
84
85
|
const globalOpts = command.optsWithGlobals();
|
|
85
|
-
if (globalOpts.json) {
|
|
86
|
+
if (options.output === 'json' || globalOpts.output === 'json') {
|
|
86
87
|
console.log(JSON.stringify({ error: err.message, code: err.code || 'ERR_REQUEST' }));
|
|
87
88
|
} else {
|
|
88
89
|
formatError(err);
|
package/src/lib/assertion.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getNestedValue } from './utils.js';
|
|
2
|
+
|
|
1
3
|
// Assertion engine for testing
|
|
2
4
|
|
|
3
5
|
export function runAssertions(response, assertions) {
|
|
@@ -75,27 +77,7 @@ export function runAssertions(response, assertions) {
|
|
|
75
77
|
return results;
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
const parts = path.split('.');
|
|
80
|
-
let current = obj;
|
|
81
|
-
|
|
82
|
-
for (const part of parts) {
|
|
83
|
-
if (current === null || current === undefined) return undefined;
|
|
84
|
-
|
|
85
|
-
// Handle array indexing
|
|
86
|
-
const arrayMatch = part.match(/^(.+?)\[(\d+)\]$/);
|
|
87
|
-
if (arrayMatch) {
|
|
88
|
-
const [, key, index] = arrayMatch;
|
|
89
|
-
current = current[key];
|
|
90
|
-
if (!Array.isArray(current)) return undefined;
|
|
91
|
-
current = current[parseInt(index)];
|
|
92
|
-
} else {
|
|
93
|
-
current = current[part];
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return current;
|
|
98
|
-
}
|
|
80
|
+
// getNestedValue imported from utils.js
|
|
99
81
|
|
|
100
82
|
function evaluateOperators(actual, expected) {
|
|
101
83
|
for (const [op, value] of Object.entries(expected)) {
|
package/src/lib/http-client.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import { request } from 'undici';
|
|
2
2
|
import { CookieJar } from 'tough-cookie';
|
|
3
3
|
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import { dirname, join } from 'path';
|
|
4
6
|
import { getCookieJarPath } from './config.js';
|
|
5
7
|
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = dirname(__filename);
|
|
10
|
+
const pkgVersion = JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')).version;
|
|
11
|
+
|
|
6
12
|
export class HttpClient {
|
|
7
13
|
constructor(options = {}) {
|
|
8
14
|
this.followRedirects = options.followRedirects !== false;
|
|
@@ -40,7 +46,7 @@ export class HttpClient {
|
|
|
40
46
|
|
|
41
47
|
// Set default User-Agent, allow override via options.headers
|
|
42
48
|
const defaultHeaders = {
|
|
43
|
-
'user-agent':
|
|
49
|
+
'user-agent': `mpx-api/${pkgVersion}`,
|
|
44
50
|
};
|
|
45
51
|
|
|
46
52
|
// Merge headers, with user headers taking precedence
|
|
@@ -52,7 +58,18 @@ export class HttpClient {
|
|
|
52
58
|
body: options.body,
|
|
53
59
|
};
|
|
54
60
|
|
|
55
|
-
//
|
|
61
|
+
// Handle redirects
|
|
62
|
+
if (!this.followRedirects) {
|
|
63
|
+
requestOptions.maxRedirections = 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Handle timeout
|
|
67
|
+
if (this.timeout) {
|
|
68
|
+
const controller = new AbortController();
|
|
69
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
70
|
+
requestOptions.signal = controller.signal;
|
|
71
|
+
requestOptions._timeoutId = timeoutId;
|
|
72
|
+
}
|
|
56
73
|
|
|
57
74
|
// Add cookies to request
|
|
58
75
|
const cookies = await this.cookieJar.getCookies(url);
|
|
@@ -72,7 +89,10 @@ export class HttpClient {
|
|
|
72
89
|
}
|
|
73
90
|
|
|
74
91
|
try {
|
|
92
|
+
const timeoutId = requestOptions._timeoutId;
|
|
93
|
+
delete requestOptions._timeoutId;
|
|
75
94
|
const response = await request(url, requestOptions);
|
|
95
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
76
96
|
|
|
77
97
|
// Store cookies from response
|
|
78
98
|
const setCookieHeaders = response.headers['set-cookie'];
|
|
@@ -119,7 +139,9 @@ export class HttpClient {
|
|
|
119
139
|
};
|
|
120
140
|
} catch (err) {
|
|
121
141
|
// Handle network errors gracefully
|
|
122
|
-
if (err.code === '
|
|
142
|
+
if (err.name === 'AbortError' || err.code === 'UND_ERR_ABORTED') {
|
|
143
|
+
throw new Error(`Request timeout after ${this.timeout}ms`);
|
|
144
|
+
} else if (err.code === 'ENOTFOUND') {
|
|
123
145
|
throw new Error(`DNS lookup failed for ${url}`);
|
|
124
146
|
} else if (err.code === 'ECONNREFUSED') {
|
|
125
147
|
throw new Error(`Connection refused to ${url}`);
|
package/src/lib/template.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getNestedValue } from './utils.js';
|
|
2
|
+
|
|
1
3
|
// Template variable interpolation
|
|
2
4
|
// Supports: {{varName}}, {{env.VAR}}, {{request-name.response.body.field}}
|
|
3
5
|
|
|
@@ -39,25 +41,4 @@ export function interpolateObject(obj, context = {}) {
|
|
|
39
41
|
return obj;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
43
|
-
// Handle array indexing: users[0].name
|
|
44
|
-
const parts = path.split('.');
|
|
45
|
-
let current = obj;
|
|
46
|
-
|
|
47
|
-
for (const part of parts) {
|
|
48
|
-
if (!current) return undefined;
|
|
49
|
-
|
|
50
|
-
// Check for array indexing
|
|
51
|
-
const arrayMatch = part.match(/^(.+?)\[(\d+)\]$/);
|
|
52
|
-
if (arrayMatch) {
|
|
53
|
-
const [, key, index] = arrayMatch;
|
|
54
|
-
current = current[key];
|
|
55
|
-
if (!Array.isArray(current)) return undefined;
|
|
56
|
-
current = current[parseInt(index)];
|
|
57
|
-
} else {
|
|
58
|
-
current = current[part];
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return current;
|
|
63
|
-
}
|
|
44
|
+
// getNestedValue imported from utils.js
|
package/src/lib/utils.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get a nested value from an object using dot notation.
|
|
3
|
+
* Supports array indexing: users[0].name
|
|
4
|
+
*/
|
|
5
|
+
export function getNestedValue(obj, path) {
|
|
6
|
+
const parts = path.split('.');
|
|
7
|
+
let current = obj;
|
|
8
|
+
|
|
9
|
+
for (const part of parts) {
|
|
10
|
+
if (current === null || current === undefined) return undefined;
|
|
11
|
+
|
|
12
|
+
// Handle array indexing
|
|
13
|
+
const arrayMatch = part.match(/^(.+?)\[(\d+)\]$/);
|
|
14
|
+
if (arrayMatch) {
|
|
15
|
+
const [, key, index] = arrayMatch;
|
|
16
|
+
current = current[key];
|
|
17
|
+
if (!Array.isArray(current)) return undefined;
|
|
18
|
+
current = current[parseInt(index)];
|
|
19
|
+
} else {
|
|
20
|
+
current = current[part];
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return current;
|
|
25
|
+
}
|
package/src/schema.js
CHANGED
|
@@ -69,16 +69,11 @@ export function getSchema() {
|
|
|
69
69
|
type: 'number',
|
|
70
70
|
default: 30000,
|
|
71
71
|
description: 'Request timeout in milliseconds'
|
|
72
|
-
},
|
|
73
|
-
'--json-output': {
|
|
74
|
-
type: 'boolean',
|
|
75
|
-
default: false,
|
|
76
|
-
description: 'Output results as structured JSON'
|
|
77
72
|
}
|
|
78
73
|
},
|
|
79
74
|
output: {
|
|
80
75
|
json: {
|
|
81
|
-
description: 'Structured response data when --
|
|
76
|
+
description: 'Structured response data when --output json is used',
|
|
82
77
|
schema: {
|
|
83
78
|
type: 'object',
|
|
84
79
|
properties: {
|
|
@@ -123,9 +118,9 @@ export function getSchema() {
|
|
|
123
118
|
1: 'Request error or 4xx/5xx status'
|
|
124
119
|
},
|
|
125
120
|
examples: [
|
|
126
|
-
{ command: 'mpx-api get https://api.example.com/users --json
|
|
127
|
-
{ command: 'mpx-api post https://api.example.com/users -j \'{"name":"John"}\' --json
|
|
128
|
-
{ command: 'mpx-api get https://api.example.com/data -H "Authorization: Bearer token"
|
|
121
|
+
{ command: 'mpx-api get https://api.example.com/users --output json', description: 'GET request with JSON output' },
|
|
122
|
+
{ command: 'mpx-api post https://api.example.com/users -j \'{"name":"John"}\' --output json', description: 'POST JSON data with structured output' },
|
|
123
|
+
{ command: 'mpx-api get https://api.example.com/data -H "Authorization: Bearer token"', description: 'GET with custom headers' }
|
|
129
124
|
]
|
|
130
125
|
},
|
|
131
126
|
collection: {
|
|
@@ -165,7 +160,7 @@ export function getSchema() {
|
|
|
165
160
|
flags: {
|
|
166
161
|
'-e, --env': { type: 'string', description: 'Environment name to use' },
|
|
167
162
|
'--base-url': { type: 'string', description: 'Override base URL' },
|
|
168
|
-
'--json
|
|
163
|
+
'--json': { type: 'boolean', description: 'Output results as JSON' }
|
|
169
164
|
}
|
|
170
165
|
},
|
|
171
166
|
list: {
|
|
@@ -177,32 +172,24 @@ export function getSchema() {
|
|
|
177
172
|
env: {
|
|
178
173
|
description: 'Manage environments',
|
|
179
174
|
subcommands: {
|
|
180
|
-
|
|
181
|
-
usage: 'mpx-api env
|
|
182
|
-
description: '
|
|
183
|
-
flags: {
|
|
184
|
-
'--json-output': { type: 'boolean', description: 'Output as JSON' }
|
|
185
|
-
}
|
|
175
|
+
init: {
|
|
176
|
+
usage: 'mpx-api env init',
|
|
177
|
+
description: 'Initialize environments directory with dev, staging, production'
|
|
186
178
|
},
|
|
187
179
|
set: {
|
|
188
|
-
usage: 'mpx-api env set <
|
|
189
|
-
description: 'Set an environment variable',
|
|
180
|
+
usage: 'mpx-api env set <environment> <variable>',
|
|
181
|
+
description: 'Set an environment variable (KEY=value format)',
|
|
190
182
|
arguments: {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
value: { type: 'string', required: true, description: 'Variable value' }
|
|
183
|
+
environment: { type: 'string', required: true, description: 'Environment name' },
|
|
184
|
+
variable: { type: 'string', required: true, description: 'Variable in KEY=value format' }
|
|
194
185
|
}
|
|
195
186
|
},
|
|
196
|
-
|
|
197
|
-
usage: 'mpx-api env
|
|
198
|
-
description: '
|
|
199
|
-
|
|
200
|
-
|
|
187
|
+
list: {
|
|
188
|
+
usage: 'mpx-api env list [environment]',
|
|
189
|
+
description: 'List all environments or variables in a specific environment',
|
|
190
|
+
arguments: {
|
|
191
|
+
environment: { type: 'string', required: false, description: 'Environment name (optional)' }
|
|
201
192
|
}
|
|
202
|
-
},
|
|
203
|
-
delete: {
|
|
204
|
-
usage: 'mpx-api env delete <name>',
|
|
205
|
-
description: 'Delete an environment'
|
|
206
193
|
}
|
|
207
194
|
}
|
|
208
195
|
},
|
|
@@ -221,11 +208,11 @@ export function getSchema() {
|
|
|
221
208
|
description: 'Run API tests',
|
|
222
209
|
usage: 'mpx-api test <file> [options]',
|
|
223
210
|
arguments: {
|
|
224
|
-
file: { type: 'string', required:
|
|
211
|
+
file: { type: 'string', required: false, description: 'Test file to run (default: .mpx-api/collection.yaml)' }
|
|
225
212
|
},
|
|
226
213
|
flags: {
|
|
227
214
|
'-e, --env': { type: 'string', description: 'Environment name' },
|
|
228
|
-
'--json
|
|
215
|
+
'--json': { type: 'boolean', description: 'Output results as JSON' }
|
|
229
216
|
}
|
|
230
217
|
},
|
|
231
218
|
history: {
|
|
@@ -233,7 +220,7 @@ export function getSchema() {
|
|
|
233
220
|
usage: 'mpx-api history [options]',
|
|
234
221
|
flags: {
|
|
235
222
|
'-n, --limit': { type: 'number', default: 10, description: 'Number of entries to show' },
|
|
236
|
-
'--
|
|
223
|
+
'--clear': { type: 'boolean', description: 'Clear history' }
|
|
237
224
|
}
|
|
238
225
|
},
|
|
239
226
|
load: {
|
|
@@ -243,9 +230,10 @@ export function getSchema() {
|
|
|
243
230
|
url: { type: 'string', required: true, description: 'Target URL' }
|
|
244
231
|
},
|
|
245
232
|
flags: {
|
|
246
|
-
'
|
|
247
|
-
'-
|
|
248
|
-
'--
|
|
233
|
+
'--rps': { type: 'number', default: 10, description: 'Requests per second' },
|
|
234
|
+
'-d, --duration': { type: 'number', default: 10, description: 'Test duration in seconds' },
|
|
235
|
+
'-H, --header': { type: 'array', description: 'Add request headers' },
|
|
236
|
+
'--method': { type: 'string', default: 'GET', description: 'HTTP method' }
|
|
249
237
|
}
|
|
250
238
|
},
|
|
251
239
|
docs: {
|
|
@@ -268,12 +256,11 @@ export function getSchema() {
|
|
|
268
256
|
}
|
|
269
257
|
},
|
|
270
258
|
globalFlags: {
|
|
271
|
-
'--
|
|
272
|
-
type: '
|
|
273
|
-
|
|
274
|
-
description: 'Output structured JSON for machine consumption'
|
|
259
|
+
'-o, --output': {
|
|
260
|
+
type: 'string',
|
|
261
|
+
description: 'Output format: "json" for structured JSON for machine consumption'
|
|
275
262
|
},
|
|
276
|
-
'--quiet': {
|
|
263
|
+
'-q, --quiet': {
|
|
277
264
|
type: 'boolean',
|
|
278
265
|
default: false,
|
|
279
266
|
description: 'Suppress non-essential output'
|