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,66 @@
1
+ import { HttpClient } from './http-client.js';
2
+ import { interpolateObject } from './template.js';
3
+ import { runAssertions } from './assertion.js';
4
+
5
+ export async function runCollection(collection, options = {}) {
6
+ const { env = {}, baseUrl = '' } = options;
7
+ const client = new HttpClient();
8
+ const results = [];
9
+ const context = { env };
10
+
11
+ for (const request of collection.requests || []) {
12
+ const result = {
13
+ name: request.name,
14
+ passed: false,
15
+ assertions: [],
16
+ error: null,
17
+ response: null,
18
+ };
19
+
20
+ try {
21
+ // Interpolate request with current context
22
+ const interpolated = interpolateObject(request, context);
23
+
24
+ // Build URL
25
+ let url = interpolated.url;
26
+ if (baseUrl && !url.startsWith('http')) {
27
+ url = baseUrl + url;
28
+ }
29
+ url = interpolateObject(url, context);
30
+
31
+ // Build request options
32
+ const requestOptions = {
33
+ headers: interpolated.headers || {},
34
+ };
35
+
36
+ if (interpolated.json) {
37
+ requestOptions.json = interpolated.json;
38
+ } else if (interpolated.body) {
39
+ requestOptions.body = interpolated.body;
40
+ }
41
+
42
+ // Make request
43
+ const response = await client.request(interpolated.method || 'GET', url, requestOptions);
44
+ result.response = response;
45
+
46
+ // Store response in context for chaining
47
+ context[request.name] = { response };
48
+
49
+ // Run assertions if present
50
+ if (interpolated.assert) {
51
+ result.assertions = runAssertions(response, interpolated.assert);
52
+ result.passed = result.assertions.every(a => a.passed);
53
+ } else {
54
+ // No assertions, just check if request succeeded
55
+ result.passed = response.status >= 200 && response.status < 400;
56
+ }
57
+ } catch (err) {
58
+ result.error = err.message;
59
+ result.passed = false;
60
+ }
61
+
62
+ results.push(result);
63
+ }
64
+
65
+ return results;
66
+ }
@@ -0,0 +1,71 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
4
+
5
+ const GLOBAL_DIR = join(homedir(), '.mpx-api');
6
+ const LOCAL_DIR = '.mpx-api';
7
+
8
+ export function ensureGlobalDir() {
9
+ if (!existsSync(GLOBAL_DIR)) {
10
+ mkdirSync(GLOBAL_DIR, { recursive: true });
11
+ }
12
+ return GLOBAL_DIR;
13
+ }
14
+
15
+ export function ensureLocalDir() {
16
+ if (!existsSync(LOCAL_DIR)) {
17
+ mkdirSync(LOCAL_DIR, { recursive: true });
18
+ }
19
+ return LOCAL_DIR;
20
+ }
21
+
22
+ export function getGlobalConfigPath() {
23
+ return join(ensureGlobalDir(), 'config.json');
24
+ }
25
+
26
+ export function getLocalConfigPath() {
27
+ return join(LOCAL_DIR, 'config.json');
28
+ }
29
+
30
+ export function getHistoryPath() {
31
+ return join(ensureGlobalDir(), 'history.jsonl');
32
+ }
33
+
34
+ export function getCookieJarPath() {
35
+ return join(ensureGlobalDir(), 'cookies.json');
36
+ }
37
+
38
+ export function loadGlobalConfig() {
39
+ const path = getGlobalConfigPath();
40
+ if (!existsSync(path)) {
41
+ return {};
42
+ }
43
+ try {
44
+ return JSON.parse(readFileSync(path, 'utf8'));
45
+ } catch (err) {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ export function saveGlobalConfig(config) {
51
+ const path = getGlobalConfigPath();
52
+ writeFileSync(path, JSON.stringify(config, null, 2));
53
+ }
54
+
55
+ export function loadLocalConfig() {
56
+ const path = getLocalConfigPath();
57
+ if (!existsSync(path)) {
58
+ return {};
59
+ }
60
+ try {
61
+ return JSON.parse(readFileSync(path, 'utf8'));
62
+ } catch (err) {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ export function saveLocalConfig(config) {
68
+ ensureLocalDir();
69
+ const path = getLocalConfigPath();
70
+ writeFileSync(path, JSON.stringify(config, null, 2));
71
+ }
@@ -0,0 +1,51 @@
1
+ import { appendFileSync, readFileSync, existsSync } from 'fs';
2
+ import { getHistoryPath } from './config.js';
3
+
4
+ export function saveToHistory(request, response) {
5
+ const historyPath = getHistoryPath();
6
+
7
+ const entry = {
8
+ timestamp: new Date().toISOString(),
9
+ method: request.method,
10
+ url: request.url,
11
+ headers: request.headers,
12
+ body: request.body,
13
+ status: response.status,
14
+ responseTime: response.responseTime,
15
+ size: response.size,
16
+ };
17
+
18
+ try {
19
+ appendFileSync(historyPath, JSON.stringify(entry) + '\n');
20
+ } catch (err) {
21
+ // Ignore errors, history is optional
22
+ }
23
+ }
24
+
25
+ export function loadHistory(limit = 50) {
26
+ const historyPath = getHistoryPath();
27
+
28
+ if (!existsSync(historyPath)) {
29
+ return [];
30
+ }
31
+
32
+ try {
33
+ const content = readFileSync(historyPath, 'utf8');
34
+ const lines = content.trim().split('\n').filter(Boolean);
35
+
36
+ // Parse and return last N entries
37
+ const entries = lines
38
+ .map(line => {
39
+ try {
40
+ return JSON.parse(line);
41
+ } catch (e) {
42
+ return null;
43
+ }
44
+ })
45
+ .filter(Boolean);
46
+
47
+ return entries.slice(-limit).reverse();
48
+ } catch (err) {
49
+ return [];
50
+ }
51
+ }
@@ -0,0 +1,179 @@
1
+ import { request } from 'undici';
2
+ import { CookieJar } from 'tough-cookie';
3
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
4
+ import { getCookieJarPath } from './config.js';
5
+
6
+ export class HttpClient {
7
+ constructor(options = {}) {
8
+ this.followRedirects = options.followRedirects !== false;
9
+ this.verifySsl = options.verifySsl !== false;
10
+ this.timeout = options.timeout || 30000;
11
+ this.cookieJar = new CookieJar();
12
+
13
+ // Load persisted cookies
14
+ this.loadCookies();
15
+ }
16
+
17
+ loadCookies() {
18
+ const cookiePath = getCookieJarPath();
19
+ if (existsSync(cookiePath)) {
20
+ try {
21
+ const data = JSON.parse(readFileSync(cookiePath, 'utf8'));
22
+ this.cookieJar = CookieJar.fromJSON(data);
23
+ } catch (err) {
24
+ // Ignore errors, start fresh
25
+ }
26
+ }
27
+ }
28
+
29
+ saveCookies() {
30
+ const cookiePath = getCookieJarPath();
31
+ try {
32
+ writeFileSync(cookiePath, JSON.stringify(this.cookieJar.toJSON()));
33
+ } catch (err) {
34
+ // Ignore errors
35
+ }
36
+ }
37
+
38
+ async request(method, url, options = {}) {
39
+ const startTime = Date.now();
40
+
41
+ const requestOptions = {
42
+ method: method.toUpperCase(),
43
+ headers: options.headers || {},
44
+ body: options.body,
45
+ };
46
+
47
+ // Note: undici follows redirects by default, maxRedirections removed in newer versions
48
+
49
+ // Add cookies to request
50
+ const cookies = await this.cookieJar.getCookies(url);
51
+ if (cookies.length > 0) {
52
+ requestOptions.headers.cookie = cookies.map(c => c.cookieString()).join('; ');
53
+ }
54
+
55
+ // Set content-type for JSON bodies
56
+ if (options.json) {
57
+ requestOptions.headers['content-type'] = 'application/json';
58
+ requestOptions.body = JSON.stringify(options.json);
59
+ }
60
+
61
+ // SSL verification
62
+ if (!this.verifySsl) {
63
+ requestOptions.connect = { rejectUnauthorized: false };
64
+ }
65
+
66
+ try {
67
+ const response = await request(url, requestOptions);
68
+
69
+ // Store cookies from response
70
+ const setCookieHeaders = response.headers['set-cookie'];
71
+ if (setCookieHeaders) {
72
+ const cookieArray = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
73
+ for (const cookie of cookieArray) {
74
+ await this.cookieJar.setCookie(cookie, url);
75
+ }
76
+ this.saveCookies();
77
+ }
78
+
79
+ // Read response body
80
+ let body;
81
+ const contentType = response.headers['content-type'] || '';
82
+ const buffer = await response.body.arrayBuffer();
83
+
84
+ // Try to parse as text
85
+ const decoder = new TextDecoder('utf-8');
86
+ const text = decoder.decode(buffer);
87
+
88
+ // Parse JSON if applicable
89
+ if (contentType.includes('application/json') || contentType.includes('application/vnd.api+json')) {
90
+ try {
91
+ body = JSON.parse(text);
92
+ } catch (err) {
93
+ body = text;
94
+ }
95
+ } else {
96
+ body = text;
97
+ }
98
+
99
+ const endTime = Date.now();
100
+
101
+ return {
102
+ status: response.statusCode,
103
+ statusText: getStatusText(response.statusCode),
104
+ headers: response.headers,
105
+ body,
106
+ rawBody: text,
107
+ size: buffer.byteLength,
108
+ responseTime: endTime - startTime,
109
+ url,
110
+ method: method.toUpperCase(),
111
+ };
112
+ } catch (err) {
113
+ // Handle network errors gracefully
114
+ if (err.code === 'ENOTFOUND') {
115
+ throw new Error(`DNS lookup failed for ${url}`);
116
+ } else if (err.code === 'ECONNREFUSED') {
117
+ throw new Error(`Connection refused to ${url}`);
118
+ } else if (err.code === 'ETIMEDOUT' || err.name === 'TimeoutError') {
119
+ throw new Error(`Request timeout after ${this.timeout}ms`);
120
+ } else if (err.code === 'CERT_HAS_EXPIRED' || err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
121
+ throw new Error(`SSL certificate error: ${err.message}. Use --no-verify to bypass.`);
122
+ } else {
123
+ throw err;
124
+ }
125
+ }
126
+ }
127
+
128
+ async get(url, options) {
129
+ return this.request('GET', url, options);
130
+ }
131
+
132
+ async post(url, options) {
133
+ return this.request('POST', url, options);
134
+ }
135
+
136
+ async put(url, options) {
137
+ return this.request('PUT', url, options);
138
+ }
139
+
140
+ async patch(url, options) {
141
+ return this.request('PATCH', url, options);
142
+ }
143
+
144
+ async delete(url, options) {
145
+ return this.request('DELETE', url, options);
146
+ }
147
+
148
+ async head(url, options) {
149
+ return this.request('HEAD', url, options);
150
+ }
151
+
152
+ async options(url, options) {
153
+ return this.request('OPTIONS', url, options);
154
+ }
155
+ }
156
+
157
+ function getStatusText(code) {
158
+ const statusTexts = {
159
+ 200: 'OK',
160
+ 201: 'Created',
161
+ 204: 'No Content',
162
+ 301: 'Moved Permanently',
163
+ 302: 'Found',
164
+ 304: 'Not Modified',
165
+ 400: 'Bad Request',
166
+ 401: 'Unauthorized',
167
+ 403: 'Forbidden',
168
+ 404: 'Not Found',
169
+ 405: 'Method Not Allowed',
170
+ 409: 'Conflict',
171
+ 422: 'Unprocessable Entity',
172
+ 429: 'Too Many Requests',
173
+ 500: 'Internal Server Error',
174
+ 502: 'Bad Gateway',
175
+ 503: 'Service Unavailable',
176
+ 504: 'Gateway Timeout',
177
+ };
178
+ return statusTexts[code] || 'Unknown';
179
+ }
@@ -0,0 +1,35 @@
1
+ import { existsSync, readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { ensureGlobalDir } from './config.js';
4
+
5
+ export function checkProLicense() {
6
+ const licenseFile = join(ensureGlobalDir(), 'license.key');
7
+
8
+ if (!existsSync(licenseFile)) {
9
+ return false;
10
+ }
11
+
12
+ try {
13
+ const license = readFileSync(licenseFile, 'utf8').trim();
14
+ // Simple validation for now (in production, use proper license verification)
15
+ return license.length > 20 && license.startsWith('MPX-PRO-');
16
+ } catch (err) {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export function requireProLicense(featureName) {
22
+ if (!checkProLicense()) {
23
+ throw new Error(
24
+ `${featureName} is a Pro feature.\n\n` +
25
+ 'Upgrade to mpx-api Pro for $12/mo to unlock:\n' +
26
+ ' • Mock server from OpenAPI specs\n' +
27
+ ' • Record & replay\n' +
28
+ ' • Load testing\n' +
29
+ ' • API documentation generation\n' +
30
+ ' • Request chaining\n' +
31
+ ' • Pre/post request scripts\n\n' +
32
+ 'Visit https://mpx-api.dev/pro to upgrade.'
33
+ );
34
+ }
35
+ }
@@ -0,0 +1,172 @@
1
+ import chalk from 'chalk';
2
+ import { highlight } from 'cli-highlight';
3
+
4
+ const MAX_BODY_SIZE = 50 * 1024; // 50KB max for terminal display
5
+
6
+ export function formatResponse(response, options = {}) {
7
+ const { verbose = false, quiet = false } = options;
8
+
9
+ if (quiet) {
10
+ // Only output body
11
+ if (typeof response.body === 'object') {
12
+ console.log(JSON.stringify(response.body, null, 2));
13
+ } else {
14
+ console.log(response.body);
15
+ }
16
+ return;
17
+ }
18
+
19
+ const lines = [];
20
+
21
+ // Status line
22
+ const statusColor = getStatusColor(response.status);
23
+ lines.push('');
24
+ lines.push(chalk.bold(`${response.method} ${response.url}`));
25
+ lines.push(
26
+ chalk[statusColor].bold(`${response.status} ${response.statusText}`) +
27
+ chalk.gray(` • ${response.responseTime}ms • ${formatSize(response.size)}`)
28
+ );
29
+ lines.push('');
30
+
31
+ // Headers (if verbose)
32
+ if (verbose) {
33
+ lines.push(chalk.bold.cyan('Response Headers:'));
34
+ for (const [key, value] of Object.entries(response.headers)) {
35
+ const valueStr = Array.isArray(value) ? value.join(', ') : value;
36
+ lines.push(chalk.cyan(` ${key}: `) + chalk.gray(valueStr));
37
+ }
38
+ lines.push('');
39
+ }
40
+
41
+ // Body
42
+ lines.push(chalk.bold.cyan('Response Body:'));
43
+
44
+ if (response.size > MAX_BODY_SIZE) {
45
+ lines.push(chalk.yellow(` [Body too large (${formatSize(response.size)}), showing first ${formatSize(MAX_BODY_SIZE)}]`));
46
+ const truncated = response.rawBody.slice(0, MAX_BODY_SIZE);
47
+ lines.push(highlightBody(truncated, response.headers['content-type']));
48
+ } else if (response.rawBody.length === 0) {
49
+ lines.push(chalk.gray(' (empty)'));
50
+ } else {
51
+ lines.push(highlightBody(response.rawBody, response.headers['content-type']));
52
+ }
53
+
54
+ lines.push('');
55
+
56
+ console.log(lines.join('\n'));
57
+ }
58
+
59
+ export function formatError(error) {
60
+ console.error('');
61
+ console.error(chalk.red.bold('✗ Error:'), error.message);
62
+ console.error('');
63
+ }
64
+
65
+ export function formatSuccess(message) {
66
+ console.log('');
67
+ console.log(chalk.green.bold('✓'), message);
68
+ console.log('');
69
+ }
70
+
71
+ export function formatInfo(message) {
72
+ console.log('');
73
+ console.log(chalk.blue.bold('ℹ'), message);
74
+ console.log('');
75
+ }
76
+
77
+ export function formatWarning(message) {
78
+ console.log('');
79
+ console.log(chalk.yellow.bold('⚠'), message);
80
+ console.log('');
81
+ }
82
+
83
+ function getStatusColor(status) {
84
+ if (status >= 200 && status < 300) return 'green';
85
+ if (status >= 300 && status < 400) return 'cyan';
86
+ if (status >= 400 && status < 500) return 'yellow';
87
+ if (status >= 500) return 'red';
88
+ return 'white';
89
+ }
90
+
91
+ function formatSize(bytes) {
92
+ if (bytes < 1024) return `${bytes} B`;
93
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
94
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
95
+ }
96
+
97
+ function highlightBody(text, contentType = '') {
98
+ try {
99
+ // Detect language from content-type
100
+ let language = 'text';
101
+ if (contentType.includes('json')) {
102
+ language = 'json';
103
+ // Pretty-print JSON
104
+ try {
105
+ const parsed = JSON.parse(text);
106
+ text = JSON.stringify(parsed, null, 2);
107
+ } catch (e) {
108
+ // Keep original if parse fails
109
+ }
110
+ } else if (contentType.includes('xml') || contentType.includes('html')) {
111
+ language = 'xml';
112
+ } else if (contentType.includes('javascript')) {
113
+ language = 'javascript';
114
+ }
115
+
116
+ const highlighted = highlight(text, { language, ignoreIllegals: true });
117
+ return highlighted.split('\n').map(line => ' ' + line).join('\n');
118
+ } catch (err) {
119
+ // Fallback to plain text with indentation
120
+ return text.split('\n').map(line => ' ' + line).join('\n');
121
+ }
122
+ }
123
+
124
+ export function formatTestResults(results) {
125
+ console.log('');
126
+ console.log(chalk.bold.cyan('Test Results:'));
127
+ console.log('');
128
+
129
+ let passed = 0;
130
+ let failed = 0;
131
+
132
+ for (const result of results) {
133
+ if (result.passed) {
134
+ passed++;
135
+ console.log(chalk.green(' ✓'), result.name);
136
+ if (result.assertions) {
137
+ for (const assertion of result.assertions) {
138
+ if (assertion.passed) {
139
+ console.log(chalk.gray(` ✓ ${assertion.description}`));
140
+ } else {
141
+ console.log(chalk.red(` ✗ ${assertion.description}`));
142
+ console.log(chalk.red(` Expected: ${assertion.expected}, Got: ${assertion.actual}`));
143
+ }
144
+ }
145
+ }
146
+ } else {
147
+ failed++;
148
+ console.log(chalk.red(' ✗'), result.name);
149
+ if (result.error) {
150
+ console.log(chalk.red(` ${result.error}`));
151
+ }
152
+ if (result.assertions) {
153
+ for (const assertion of result.assertions) {
154
+ if (!assertion.passed) {
155
+ console.log(chalk.red(` ✗ ${assertion.description}`));
156
+ console.log(chalk.red(` Expected: ${assertion.expected}, Got: ${assertion.actual}`));
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ console.log('');
164
+ console.log(
165
+ chalk.bold(`Total: ${results.length} tests, `) +
166
+ chalk.green.bold(`${passed} passed, `) +
167
+ (failed > 0 ? chalk.red.bold(`${failed} failed`) : chalk.gray(`${failed} failed`))
168
+ );
169
+ console.log('');
170
+
171
+ return failed === 0;
172
+ }
@@ -0,0 +1,63 @@
1
+ // Template variable interpolation
2
+ // Supports: {{varName}}, {{env.VAR}}, {{request-name.response.body.field}}
3
+
4
+ export function interpolate(text, context = {}) {
5
+ if (typeof text !== 'string') return text;
6
+
7
+ return text.replace(/\{\{(.+?)\}\}/g, (match, path) => {
8
+ path = path.trim();
9
+
10
+ // Handle environment variables
11
+ if (path.startsWith('env.')) {
12
+ const envVar = path.slice(4);
13
+ return process.env[envVar] || match;
14
+ }
15
+
16
+ // Handle nested object paths (e.g., response.body.users[0].id)
17
+ const value = getNestedValue(context, path);
18
+ return value !== undefined ? value : match;
19
+ });
20
+ }
21
+
22
+ export function interpolateObject(obj, context = {}) {
23
+ if (typeof obj === 'string') {
24
+ return interpolate(obj, context);
25
+ }
26
+
27
+ if (Array.isArray(obj)) {
28
+ return obj.map(item => interpolateObject(item, context));
29
+ }
30
+
31
+ if (obj && typeof obj === 'object') {
32
+ const result = {};
33
+ for (const [key, value] of Object.entries(obj)) {
34
+ result[key] = interpolateObject(value, context);
35
+ }
36
+ return result;
37
+ }
38
+
39
+ return obj;
40
+ }
41
+
42
+ function getNestedValue(obj, path) {
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
+ }