mpx-api 1.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.
@@ -0,0 +1,161 @@
1
+ import { request } from 'undici';
2
+ import { formatError, formatSuccess, formatInfo } from '../lib/output.js';
3
+ import { requireProLicense } from '../lib/license.js';
4
+ import chalk from 'chalk';
5
+
6
+ export function registerLoadCommand(program) {
7
+ program
8
+ .command('load <url>')
9
+ .description('Run load test against URL (Pro)')
10
+ .option('--rps <number>', 'Requests per second', '10')
11
+ .option('-d, --duration <seconds>', 'Test duration in seconds', '10')
12
+ .option('-H, --header <header...>', 'Add request headers')
13
+ .option('--method <method>', 'HTTP method', 'GET')
14
+ .action(async (url, options) => {
15
+ try {
16
+ requireProLicense('Load testing');
17
+
18
+ const rps = parseInt(options.rps);
19
+ const duration = parseInt(options.duration);
20
+ const method = options.method.toUpperCase();
21
+
22
+ formatInfo(`Starting load test: ${rps} req/s for ${duration}s`);
23
+ formatInfo(`Target: ${method} ${url}`);
24
+ console.log('');
25
+
26
+ const headers = {};
27
+ if (options.header) {
28
+ for (const header of options.header) {
29
+ const [key, ...valueParts] = header.split(':');
30
+ headers[key.trim()] = valueParts.join(':').trim();
31
+ }
32
+ }
33
+
34
+ const results = await runLoadTest(url, {
35
+ rps,
36
+ duration,
37
+ method,
38
+ headers,
39
+ });
40
+
41
+ displayLoadTestResults(results);
42
+
43
+ } catch (err) {
44
+ formatError(err);
45
+ process.exit(1);
46
+ }
47
+ });
48
+ }
49
+
50
+ async function runLoadTest(url, options) {
51
+ const { rps, duration, method, headers } = options;
52
+ const interval = 1000 / rps; // ms between requests
53
+ const totalRequests = rps * duration;
54
+
55
+ const results = {
56
+ total: totalRequests,
57
+ success: 0,
58
+ failed: 0,
59
+ responseTimes: [],
60
+ errors: {},
61
+ statusCodes: {},
62
+ };
63
+
64
+ const startTime = Date.now();
65
+ const promises = [];
66
+
67
+ for (let i = 0; i < totalRequests; i++) {
68
+ const delay = i * interval;
69
+
70
+ promises.push(
71
+ new Promise((resolve) => {
72
+ setTimeout(async () => {
73
+ const reqStart = Date.now();
74
+
75
+ try {
76
+ const response = await request(url, { method, headers });
77
+ const reqTime = Date.now() - reqStart;
78
+
79
+ await response.body.arrayBuffer(); // Consume body
80
+
81
+ results.success++;
82
+ results.responseTimes.push(reqTime);
83
+ results.statusCodes[response.statusCode] = (results.statusCodes[response.statusCode] || 0) + 1;
84
+ } catch (err) {
85
+ results.failed++;
86
+ const errorType = err.code || err.message;
87
+ results.errors[errorType] = (results.errors[errorType] || 0) + 1;
88
+ }
89
+
90
+ resolve();
91
+ }, delay);
92
+ })
93
+ );
94
+ }
95
+
96
+ await Promise.all(promises);
97
+
98
+ results.totalTime = Date.now() - startTime;
99
+
100
+ // Calculate percentiles
101
+ if (results.responseTimes.length > 0) {
102
+ results.responseTimes.sort((a, b) => a - b);
103
+ results.p50 = percentile(results.responseTimes, 50);
104
+ results.p95 = percentile(results.responseTimes, 95);
105
+ results.p99 = percentile(results.responseTimes, 99);
106
+ results.min = results.responseTimes[0];
107
+ results.max = results.responseTimes[results.responseTimes.length - 1];
108
+ results.avg = results.responseTimes.reduce((a, b) => a + b, 0) / results.responseTimes.length;
109
+ }
110
+
111
+ return results;
112
+ }
113
+
114
+ function percentile(arr, p) {
115
+ const index = Math.ceil((arr.length * p) / 100) - 1;
116
+ return arr[index];
117
+ }
118
+
119
+ function displayLoadTestResults(results) {
120
+ console.log('');
121
+ console.log(chalk.bold.cyan('Load Test Results:'));
122
+ console.log('');
123
+
124
+ console.log(chalk.bold('Summary:'));
125
+ console.log(` Total requests: ${results.total}`);
126
+ console.log(` ${chalk.green('Successful:')} ${results.success}`);
127
+ if (results.failed > 0) {
128
+ console.log(` ${chalk.red('Failed:')} ${results.failed}`);
129
+ }
130
+ console.log(` Duration: ${(results.totalTime / 1000).toFixed(2)}s`);
131
+ console.log(` Actual RPS: ${(results.total / (results.totalTime / 1000)).toFixed(2)}`);
132
+ console.log('');
133
+
134
+ if (results.responseTimes.length > 0) {
135
+ console.log(chalk.bold('Response Times:'));
136
+ console.log(` Min: ${results.min}ms`);
137
+ console.log(` Max: ${results.max}ms`);
138
+ console.log(` Avg: ${results.avg.toFixed(2)}ms`);
139
+ console.log(` P50: ${results.p50}ms`);
140
+ console.log(` P95: ${results.p95}ms`);
141
+ console.log(` P99: ${results.p99}ms`);
142
+ console.log('');
143
+ }
144
+
145
+ if (Object.keys(results.statusCodes).length > 0) {
146
+ console.log(chalk.bold('Status Codes:'));
147
+ for (const [code, count] of Object.entries(results.statusCodes)) {
148
+ const color = code.startsWith('2') ? 'green' : code.startsWith('4') || code.startsWith('5') ? 'red' : 'white';
149
+ console.log(` ${chalk[color](code)}: ${count}`);
150
+ }
151
+ console.log('');
152
+ }
153
+
154
+ if (Object.keys(results.errors).length > 0) {
155
+ console.log(chalk.bold.red('Errors:'));
156
+ for (const [error, count] of Object.entries(results.errors)) {
157
+ console.log(` ${error}: ${count}`);
158
+ }
159
+ console.log('');
160
+ }
161
+ }
@@ -0,0 +1,178 @@
1
+ import { createServer } from 'http';
2
+ import { readFileSync, existsSync } from 'fs';
3
+ import { parse as parseYaml } from 'yaml';
4
+ import { formatError, formatInfo, formatSuccess } from '../lib/output.js';
5
+ import { requireProLicense } from '../lib/license.js';
6
+ import chalk from 'chalk';
7
+
8
+ export function registerMockCommands(program) {
9
+ const mock = program
10
+ .command('mock')
11
+ .description('Mock server commands');
12
+
13
+ mock
14
+ .command('start [spec]')
15
+ .description('Start mock server from OpenAPI spec (Pro)')
16
+ .option('-p, --port <port>', 'Server port', '3000')
17
+ .option('-d, --delay <ms>', 'Response delay in milliseconds', '0')
18
+ .option('--cors', 'Enable CORS headers')
19
+ .action((spec, options) => {
20
+ try {
21
+ requireProLicense('Mock server');
22
+
23
+ if (!spec) {
24
+ formatError(new Error('OpenAPI spec file required'));
25
+ process.exit(1);
26
+ }
27
+
28
+ if (!existsSync(spec)) {
29
+ formatError(new Error(`Spec file not found: ${spec}`));
30
+ process.exit(1);
31
+ }
32
+
33
+ const content = readFileSync(spec, 'utf8');
34
+ let openApiSpec;
35
+
36
+ try {
37
+ if (spec.endsWith('.yaml') || spec.endsWith('.yml')) {
38
+ openApiSpec = parseYaml(content);
39
+ } else {
40
+ openApiSpec = JSON.parse(content);
41
+ }
42
+ } catch (err) {
43
+ formatError(new Error(`Failed to parse spec: ${err.message}`));
44
+ process.exit(1);
45
+ }
46
+
47
+ const port = parseInt(options.port);
48
+ const delay = parseInt(options.delay);
49
+
50
+ const server = createMockServer(openApiSpec, {
51
+ delay,
52
+ cors: options.cors,
53
+ });
54
+
55
+ server.listen(port, () => {
56
+ formatSuccess(`Mock server started on http://localhost:${port}`);
57
+ formatInfo(`Serving from: ${spec}`);
58
+ if (delay > 0) {
59
+ formatInfo(`Response delay: ${delay}ms`);
60
+ }
61
+ console.log('');
62
+ console.log(chalk.gray('Press Ctrl+C to stop'));
63
+ console.log('');
64
+ });
65
+
66
+ } catch (err) {
67
+ formatError(err);
68
+ process.exit(1);
69
+ }
70
+ });
71
+ }
72
+
73
+ function createMockServer(spec, options = {}) {
74
+ const { delay = 0, cors = false } = options;
75
+ const paths = spec.paths || {};
76
+
77
+ return createServer((req, res) => {
78
+ const start = Date.now();
79
+
80
+ // CORS headers
81
+ if (cors) {
82
+ res.setHeader('Access-Control-Allow-Origin', '*');
83
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS');
84
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
85
+
86
+ if (req.method === 'OPTIONS') {
87
+ res.writeHead(204);
88
+ res.end();
89
+ return;
90
+ }
91
+ }
92
+
93
+ // Find matching path
94
+ let matchedPath = null;
95
+ let matchedOperation = null;
96
+
97
+ for (const [path, operations] of Object.entries(paths)) {
98
+ // Simple path matching (no parameters for now)
99
+ const pattern = path.replace(/\{[^}]+\}/g, '[^/]+');
100
+ const regex = new RegExp(`^${pattern}$`);
101
+
102
+ if (regex.test(req.url)) {
103
+ const method = req.method.toLowerCase();
104
+ if (operations[method]) {
105
+ matchedPath = path;
106
+ matchedOperation = operations[method];
107
+ break;
108
+ }
109
+ }
110
+ }
111
+
112
+ // Apply delay
113
+ setTimeout(() => {
114
+ if (!matchedOperation) {
115
+ res.writeHead(404, { 'Content-Type': 'application/json' });
116
+ res.end(JSON.stringify({ error: 'Not found' }));
117
+ logRequest(req, 404, Date.now() - start);
118
+ return;
119
+ }
120
+
121
+ // Get first success response
122
+ const responses = matchedOperation.responses || {};
123
+ const successCode = Object.keys(responses).find(code => code.startsWith('2')) || '200';
124
+ const response = responses[successCode];
125
+
126
+ // Generate mock response
127
+ const mockData = generateMockFromSchema(response.content?.['application/json']?.schema || {});
128
+
129
+ res.writeHead(parseInt(successCode), { 'Content-Type': 'application/json' });
130
+ res.end(JSON.stringify(mockData, null, 2));
131
+
132
+ logRequest(req, successCode, Date.now() - start);
133
+ }, delay);
134
+ });
135
+ }
136
+
137
+ function generateMockFromSchema(schema) {
138
+ if (!schema) return {};
139
+
140
+ if (schema.type === 'object') {
141
+ const obj = {};
142
+ for (const [key, propSchema] of Object.entries(schema.properties || {})) {
143
+ obj[key] = generateMockFromSchema(propSchema);
144
+ }
145
+ return obj;
146
+ }
147
+
148
+ if (schema.type === 'array') {
149
+ return [generateMockFromSchema(schema.items || {})];
150
+ }
151
+
152
+ if (schema.example !== undefined) {
153
+ return schema.example;
154
+ }
155
+
156
+ // Default values by type
157
+ const defaults = {
158
+ string: 'example',
159
+ number: 42,
160
+ integer: 42,
161
+ boolean: true,
162
+ null: null,
163
+ };
164
+
165
+ return defaults[schema.type] || null;
166
+ }
167
+
168
+ function logRequest(req, status, duration) {
169
+ const statusColor = status >= 200 && status < 300 ? 'green' :
170
+ status >= 400 ? 'yellow' : 'white';
171
+
172
+ console.log(
173
+ chalk[statusColor](status.toString().padEnd(4)) +
174
+ chalk.bold(req.method.padEnd(7)) +
175
+ chalk.gray(`${duration}ms`.padEnd(8)) +
176
+ req.url
177
+ );
178
+ }
@@ -0,0 +1,91 @@
1
+ import { HttpClient } from '../lib/http-client.js';
2
+ import { formatResponse, formatError } from '../lib/output.js';
3
+ import { saveToHistory } from '../lib/history.js';
4
+
5
+ export function registerRequestCommands(program) {
6
+ const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
7
+
8
+ for (const method of methods) {
9
+ program
10
+ .command(`${method} <url>`)
11
+ .description(`Send a ${method.toUpperCase()} request`)
12
+ .option('-H, --header <header...>', 'Add request headers (key:value or "key: value")')
13
+ .option('-j, --json <data>', 'Send JSON data (automatically sets Content-Type)')
14
+ .option('-d, --data <data>', 'Send raw request body')
15
+ .option('-v, --verbose', 'Show response headers')
16
+ .option('-q, --quiet', 'Only output response body')
17
+ .option('--no-follow', 'Do not follow redirects')
18
+ .option('--no-verify', 'Skip SSL certificate verification')
19
+ .option('--timeout <ms>', 'Request timeout in milliseconds', '30000')
20
+ .action(async (url, options) => {
21
+ try {
22
+ const client = new HttpClient({
23
+ followRedirects: options.follow,
24
+ verifySsl: options.verify,
25
+ timeout: parseInt(options.timeout),
26
+ });
27
+
28
+ const requestOptions = {
29
+ headers: parseHeaders(options.header || []),
30
+ };
31
+
32
+ // Handle JSON data
33
+ if (options.json) {
34
+ try {
35
+ requestOptions.json = JSON.parse(options.json);
36
+ } catch (err) {
37
+ formatError(new Error(`Invalid JSON: ${err.message}`));
38
+ process.exit(1);
39
+ }
40
+ } else if (options.data) {
41
+ requestOptions.body = options.data;
42
+ }
43
+
44
+ const response = await client.request(method, url, requestOptions);
45
+
46
+ // Save to history
47
+ saveToHistory(
48
+ {
49
+ method: method.toUpperCase(),
50
+ url,
51
+ headers: requestOptions.headers,
52
+ body: requestOptions.json || requestOptions.body,
53
+ },
54
+ response
55
+ );
56
+
57
+ // Format and display response
58
+ formatResponse(response, {
59
+ verbose: options.verbose,
60
+ quiet: options.quiet,
61
+ });
62
+
63
+ // Exit with non-zero code for 4xx/5xx errors
64
+ if (response.status >= 400) {
65
+ process.exit(1);
66
+ }
67
+ } catch (err) {
68
+ formatError(err);
69
+ process.exit(1);
70
+ }
71
+ });
72
+ }
73
+ }
74
+
75
+ function parseHeaders(headerArray) {
76
+ const headers = {};
77
+
78
+ for (const header of headerArray) {
79
+ // Support both "key:value" and "key: value" formats
80
+ const colonIndex = header.indexOf(':');
81
+ if (colonIndex === -1) {
82
+ throw new Error(`Invalid header format: ${header}. Expected "key:value" or "key: value"`);
83
+ }
84
+
85
+ const key = header.slice(0, colonIndex).trim();
86
+ const value = header.slice(colonIndex + 1).trim();
87
+ headers[key] = value;
88
+ }
89
+
90
+ return headers;
91
+ }
@@ -0,0 +1,55 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { parse } from 'yaml';
3
+ import { formatError } from '../lib/output.js';
4
+ import { runCollection } from '../lib/collection-runner.js';
5
+ import { formatTestResults } from '../lib/output.js';
6
+ import { join } from 'path';
7
+
8
+ export function registerTestCommand(program) {
9
+ program
10
+ .command('test [file]')
11
+ .description('Run tests from a collection file')
12
+ .option('-e, --env <name>', 'Environment to use')
13
+ .option('--base-url <url>', 'Override base URL')
14
+ .option('--json', 'Output results as JSON')
15
+ .action(async (file, options) => {
16
+ try {
17
+ const collectionPath = file || join('.mpx-api', 'collection.yaml');
18
+
19
+ if (!existsSync(collectionPath)) {
20
+ formatError(new Error(`Collection not found: ${collectionPath}`));
21
+ process.exit(1);
22
+ }
23
+
24
+ const content = readFileSync(collectionPath, 'utf8');
25
+ const collection = parse(content);
26
+
27
+ // Load environment if specified
28
+ let env = {};
29
+ if (options.env) {
30
+ const envPath = join('.mpx-api', 'environments', `${options.env}.yaml`);
31
+ if (existsSync(envPath)) {
32
+ const envContent = readFileSync(envPath, 'utf8');
33
+ env = parse(envContent);
34
+ env = env.variables || {};
35
+ }
36
+ }
37
+
38
+ const baseUrl = options.baseUrl || collection.baseUrl || '';
39
+
40
+ const results = await runCollection(collection, { env, baseUrl });
41
+
42
+ if (options.json) {
43
+ console.log(JSON.stringify(results, null, 2));
44
+ const allPassed = results.every(r => r.passed);
45
+ process.exit(allPassed ? 0 : 1);
46
+ } else {
47
+ const allPassed = formatTestResults(results);
48
+ process.exit(allPassed ? 0 : 1);
49
+ }
50
+ } catch (err) {
51
+ formatError(err);
52
+ process.exit(1);
53
+ }
54
+ });
55
+ }
@@ -0,0 +1,170 @@
1
+ // Assertion engine for testing
2
+
3
+ export function runAssertions(response, assertions) {
4
+ const results = [];
5
+
6
+ for (const [path, expected] of Object.entries(assertions)) {
7
+ const result = {
8
+ path,
9
+ expected,
10
+ actual: undefined,
11
+ passed: false,
12
+ description: '',
13
+ };
14
+
15
+ // Special case: status code
16
+ if (path === 'status') {
17
+ result.actual = response.status;
18
+ result.passed = response.status === expected;
19
+ result.description = `Status code is ${expected}`;
20
+ results.push(result);
21
+ continue;
22
+ }
23
+
24
+ // Special case: responseTime
25
+ if (path === 'responseTime') {
26
+ result.actual = response.responseTime;
27
+ if (typeof expected === 'object') {
28
+ // Handle operators: { lt: 500, gt: 100 }
29
+ result.passed = evaluateOperators(response.responseTime, expected);
30
+ result.description = `Response time ${formatOperators(expected)}`;
31
+ } else {
32
+ result.passed = response.responseTime === expected;
33
+ result.description = `Response time is ${expected}ms`;
34
+ }
35
+ results.push(result);
36
+ continue;
37
+ }
38
+
39
+ // Handle headers.* paths
40
+ if (path.startsWith('headers.')) {
41
+ const headerName = path.slice(8).toLowerCase();
42
+ const actualValue = response.headers[headerName];
43
+ result.actual = actualValue;
44
+
45
+ if (typeof expected === 'string') {
46
+ result.passed = actualValue === expected || (actualValue && actualValue.includes(expected));
47
+ result.description = `Header ${headerName} contains "${expected}"`;
48
+ } else if (typeof expected === 'object') {
49
+ result.passed = evaluateOperators(actualValue, expected);
50
+ result.description = `Header ${headerName} ${formatOperators(expected)}`;
51
+ }
52
+ results.push(result);
53
+ continue;
54
+ }
55
+
56
+ // Handle body.* paths
57
+ if (path.startsWith('body.')) {
58
+ const bodyPath = path.slice(5);
59
+ const actualValue = getNestedValue(response.body, bodyPath);
60
+ result.actual = actualValue;
61
+
62
+ if (typeof expected === 'object' && !Array.isArray(expected)) {
63
+ // Handle operators
64
+ result.passed = evaluateOperators(actualValue, expected);
65
+ result.description = `${path} ${formatOperators(expected)}`;
66
+ } else {
67
+ result.passed = deepEqual(actualValue, expected);
68
+ result.description = `${path} equals ${JSON.stringify(expected)}`;
69
+ }
70
+ results.push(result);
71
+ continue;
72
+ }
73
+ }
74
+
75
+ return results;
76
+ }
77
+
78
+ function getNestedValue(obj, path) {
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
+ }
99
+
100
+ function evaluateOperators(actual, expected) {
101
+ for (const [op, value] of Object.entries(expected)) {
102
+ switch (op) {
103
+ case 'eq':
104
+ if (actual !== value) return false;
105
+ break;
106
+ case 'ne':
107
+ if (actual === value) return false;
108
+ break;
109
+ case 'gt':
110
+ if (!(actual > value)) return false;
111
+ break;
112
+ case 'gte':
113
+ if (!(actual >= value)) return false;
114
+ break;
115
+ case 'lt':
116
+ if (!(actual < value)) return false;
117
+ break;
118
+ case 'lte':
119
+ if (!(actual <= value)) return false;
120
+ break;
121
+ case 'contains':
122
+ if (!actual || !actual.includes(value)) return false;
123
+ break;
124
+ case 'exists':
125
+ if (value && actual === undefined) return false;
126
+ if (!value && actual !== undefined) return false;
127
+ break;
128
+ default:
129
+ return false;
130
+ }
131
+ }
132
+ return true;
133
+ }
134
+
135
+ function formatOperators(expected) {
136
+ const parts = [];
137
+ for (const [op, value] of Object.entries(expected)) {
138
+ switch (op) {
139
+ case 'eq': parts.push(`equals ${value}`); break;
140
+ case 'ne': parts.push(`does not equal ${value}`); break;
141
+ case 'gt': parts.push(`greater than ${value}`); break;
142
+ case 'gte': parts.push(`greater than or equal to ${value}`); break;
143
+ case 'lt': parts.push(`less than ${value}`); break;
144
+ case 'lte': parts.push(`less than or equal to ${value}`); break;
145
+ case 'contains': parts.push(`contains "${value}"`); break;
146
+ case 'exists': parts.push(value ? 'exists' : 'does not exist'); break;
147
+ }
148
+ }
149
+ return parts.join(' and ');
150
+ }
151
+
152
+ function deepEqual(a, b) {
153
+ if (a === b) return true;
154
+ if (a == null || b == null) return false;
155
+ if (typeof a !== typeof b) return false;
156
+
157
+ if (Array.isArray(a)) {
158
+ if (!Array.isArray(b) || a.length !== b.length) return false;
159
+ return a.every((item, i) => deepEqual(item, b[i]));
160
+ }
161
+
162
+ if (typeof a === 'object') {
163
+ const keysA = Object.keys(a);
164
+ const keysB = Object.keys(b);
165
+ if (keysA.length !== keysB.length) return false;
166
+ return keysA.every(key => deepEqual(a[key], b[key]));
167
+ }
168
+
169
+ return false;
170
+ }