apigrip 0.1.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/README.md +240 -0
- package/cli/commands/curl.js +11 -0
- package/cli/commands/env.js +174 -0
- package/cli/commands/last.js +63 -0
- package/cli/commands/list.js +35 -0
- package/cli/commands/mcp.js +25 -0
- package/cli/commands/projects.js +50 -0
- package/cli/commands/send.js +189 -0
- package/cli/commands/serve.js +46 -0
- package/cli/index.js +109 -0
- package/cli/output.js +168 -0
- package/cli/resolve-project.js +43 -0
- package/client/dist/assets/index-CtHBIuEv.js +75 -0
- package/client/dist/assets/index-kzeRjfI8.css +1 -0
- package/client/dist/index.html +19 -0
- package/core/curl-builder.js +218 -0
- package/core/curl-executor.js +370 -0
- package/core/env-resolver.js +244 -0
- package/core/git-info.js +41 -0
- package/core/params-store.js +94 -0
- package/core/preferences-store.js +150 -0
- package/core/projects-store.js +173 -0
- package/core/response-store.js +121 -0
- package/core/schema-validator.js +196 -0
- package/core/spec-discovery.js +109 -0
- package/core/spec-parser.js +172 -0
- package/lib/index.cjs +16 -0
- package/lib/index.js +294 -0
- package/mcp/server.js +257 -0
- package/package.json +70 -0
- package/server/index.js +53 -0
- package/server/routes/browse.js +61 -0
- package/server/routes/environments.js +92 -0
- package/server/routes/events.js +40 -0
- package/server/routes/params.js +38 -0
- package/server/routes/preferences.js +27 -0
- package/server/routes/project.js +94 -0
- package/server/routes/projects.js +51 -0
- package/server/routes/requests.js +192 -0
- package/server/routes/spec.js +92 -0
- package/server/spec-watcher.js +236 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getConfigDir, getProjectHash } from './env-resolver.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Get the responses file path for a project.
|
|
7
|
+
*/
|
|
8
|
+
function getResponsesFilePath(projectDir) {
|
|
9
|
+
const configDir = getConfigDir();
|
|
10
|
+
const hash = getProjectHash(projectDir);
|
|
11
|
+
return path.join(configDir, 'responses', `${hash}.json`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read and parse the responses file.
|
|
16
|
+
* Returns an empty object if the file doesn't exist or is corrupt.
|
|
17
|
+
*/
|
|
18
|
+
function readResponsesFile(filePath) {
|
|
19
|
+
try {
|
|
20
|
+
if (!fs.existsSync(filePath)) {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
26
|
+
console.warn(`[response-store] Corrupt responses file at ${filePath}, returning defaults`);
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
return parsed;
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.warn(`[response-store] Failed to read responses file at ${filePath}: ${err.message}`);
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Write the full responses object to the responses file.
|
|
38
|
+
*/
|
|
39
|
+
function writeResponsesFile(filePath, data) {
|
|
40
|
+
const dir = path.dirname(filePath);
|
|
41
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
42
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Save the last response for a specific endpoint.
|
|
47
|
+
* @param {string} projectDir - Project directory path
|
|
48
|
+
* @param {string} method - HTTP method (e.g., "GET")
|
|
49
|
+
* @param {string} endpointPath - Endpoint path (e.g., "/users/{id}")
|
|
50
|
+
* @param {object} response - Response object to save
|
|
51
|
+
* @param {number} response.status - HTTP status code
|
|
52
|
+
* @param {string} [response.status_text] - HTTP status text
|
|
53
|
+
* @param {object} [response.headers] - Response headers
|
|
54
|
+
* @param {string} [response.body] - Response body
|
|
55
|
+
* @param {number} [response.size_bytes] - Response size
|
|
56
|
+
* @param {object} [response.timing] - Timing info (dns_ms, tcp_ms, tls_ms, ttfb_ms, total_ms)
|
|
57
|
+
* @param {string} [response.curl_command] - curl command used
|
|
58
|
+
* @param {object} [response.validation] - Schema validation result
|
|
59
|
+
*/
|
|
60
|
+
export function saveLastResponse(projectDir, method, endpointPath, response) {
|
|
61
|
+
const filePath = getResponsesFilePath(projectDir);
|
|
62
|
+
const all = readResponsesFile(filePath);
|
|
63
|
+
const key = `${method.toUpperCase()} ${endpointPath}`;
|
|
64
|
+
all[key] = {
|
|
65
|
+
...response,
|
|
66
|
+
saved_at: new Date().toISOString(),
|
|
67
|
+
};
|
|
68
|
+
writeResponsesFile(filePath, all);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Load the last response for a specific endpoint.
|
|
73
|
+
* @param {string} projectDir - Project directory path
|
|
74
|
+
* @param {string} method - HTTP method (e.g., "GET")
|
|
75
|
+
* @param {string} endpointPath - Endpoint path (e.g., "/users/{id}")
|
|
76
|
+
* @returns {object|null} The saved response or null if none exists
|
|
77
|
+
*/
|
|
78
|
+
export function loadLastResponse(projectDir, method, endpointPath) {
|
|
79
|
+
const filePath = getResponsesFilePath(projectDir);
|
|
80
|
+
const all = readResponsesFile(filePath);
|
|
81
|
+
const key = `${method.toUpperCase()} ${endpointPath}`;
|
|
82
|
+
return all[key] || null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Load all saved responses for a project.
|
|
87
|
+
* @param {string} projectDir - Project directory path
|
|
88
|
+
* @returns {object} Full responses object keyed by "METHOD /path"
|
|
89
|
+
*/
|
|
90
|
+
export function loadAllResponses(projectDir) {
|
|
91
|
+
const filePath = getResponsesFilePath(projectDir);
|
|
92
|
+
return readResponsesFile(filePath);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Clear the saved response for a specific endpoint.
|
|
97
|
+
* @param {string} projectDir - Project directory path
|
|
98
|
+
* @param {string} method - HTTP method
|
|
99
|
+
* @param {string} endpointPath - Endpoint path
|
|
100
|
+
* @returns {boolean} True if a response was cleared, false if none existed
|
|
101
|
+
*/
|
|
102
|
+
export function clearLastResponse(projectDir, method, endpointPath) {
|
|
103
|
+
const filePath = getResponsesFilePath(projectDir);
|
|
104
|
+
const all = readResponsesFile(filePath);
|
|
105
|
+
const key = `${method.toUpperCase()} ${endpointPath}`;
|
|
106
|
+
if (key in all) {
|
|
107
|
+
delete all[key];
|
|
108
|
+
writeResponsesFile(filePath, all);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Clear all saved responses for a project.
|
|
116
|
+
* @param {string} projectDir - Project directory path
|
|
117
|
+
*/
|
|
118
|
+
export function clearAllResponses(projectDir) {
|
|
119
|
+
const filePath = getResponsesFilePath(projectDir);
|
|
120
|
+
writeResponsesFile(filePath, {});
|
|
121
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema-validator.js - Validate JSON against JSON Schema using ajv.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import Ajv from 'ajv';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Create a configured Ajv instance.
|
|
9
|
+
*/
|
|
10
|
+
function createAjv() {
|
|
11
|
+
return new Ajv({
|
|
12
|
+
allErrors: true,
|
|
13
|
+
strict: false,
|
|
14
|
+
validateFormats: false,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Convert Ajv errors to our standard error format.
|
|
20
|
+
*
|
|
21
|
+
* @param {Array} ajvErrors - Errors from ajv.errors
|
|
22
|
+
* @returns {Array<{path: string, message: string, expected: string, actual: string}>}
|
|
23
|
+
*/
|
|
24
|
+
function formatErrors(ajvErrors) {
|
|
25
|
+
if (!ajvErrors || ajvErrors.length === 0) return [];
|
|
26
|
+
|
|
27
|
+
return ajvErrors.map(err => {
|
|
28
|
+
const path = err.instancePath || '/';
|
|
29
|
+
let expected = '';
|
|
30
|
+
let actual = '';
|
|
31
|
+
|
|
32
|
+
if (err.keyword === 'type') {
|
|
33
|
+
expected = String(err.params.type);
|
|
34
|
+
actual = err.data != null ? typeof err.data : 'undefined';
|
|
35
|
+
} else if (err.keyword === 'required') {
|
|
36
|
+
expected = 'required';
|
|
37
|
+
actual = 'missing';
|
|
38
|
+
} else if (err.keyword === 'enum') {
|
|
39
|
+
expected = `one of: ${(err.params.allowedValues || []).join(', ')}`;
|
|
40
|
+
actual = String(err.data);
|
|
41
|
+
} else if (err.keyword === 'format') {
|
|
42
|
+
expected = `${err.params.format} format`;
|
|
43
|
+
actual = String(err.data);
|
|
44
|
+
} else if (err.keyword === 'minimum' || err.keyword === 'maximum') {
|
|
45
|
+
expected = `${err.keyword}: ${err.params.limit}`;
|
|
46
|
+
actual = String(err.data);
|
|
47
|
+
} else if (err.keyword === 'minLength' || err.keyword === 'maxLength') {
|
|
48
|
+
expected = `${err.keyword}: ${err.params.limit}`;
|
|
49
|
+
actual = `length: ${String(err.data).length}`;
|
|
50
|
+
} else if (err.keyword === 'additionalProperties') {
|
|
51
|
+
expected = 'no additional properties';
|
|
52
|
+
actual = `property: ${err.params.additionalProperty}`;
|
|
53
|
+
} else if (err.keyword === 'pattern') {
|
|
54
|
+
expected = `pattern: ${err.params.pattern}`;
|
|
55
|
+
actual = String(err.data);
|
|
56
|
+
} else {
|
|
57
|
+
expected = err.keyword || '';
|
|
58
|
+
actual = err.data != null ? String(err.data) : '';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
path,
|
|
63
|
+
message: err.message || 'Validation error',
|
|
64
|
+
expected,
|
|
65
|
+
actual,
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Validate a request body against a JSON schema.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} schema - JSON Schema to validate against
|
|
74
|
+
* @param {string} body - Body string to validate
|
|
75
|
+
* @param {string} contentType - Content type of the body
|
|
76
|
+
* @returns {{ valid: boolean|null, errors: Array }}
|
|
77
|
+
*/
|
|
78
|
+
export function validateBody(schema, body, contentType) {
|
|
79
|
+
// For non-JSON content types, skip validation
|
|
80
|
+
if (!contentType || !contentType.includes('json')) {
|
|
81
|
+
return { valid: null, errors: [] };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!schema) {
|
|
85
|
+
return { valid: null, errors: [] };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Parse body as JSON
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = JSON.parse(body);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return {
|
|
94
|
+
valid: false,
|
|
95
|
+
errors: [{
|
|
96
|
+
path: '/',
|
|
97
|
+
message: `Invalid JSON: ${err.message}`,
|
|
98
|
+
expected: 'valid JSON',
|
|
99
|
+
actual: 'parse error',
|
|
100
|
+
}],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const ajv = createAjv();
|
|
105
|
+
let validate;
|
|
106
|
+
try {
|
|
107
|
+
validate = ajv.compile(schema);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return {
|
|
110
|
+
valid: null,
|
|
111
|
+
errors: [{
|
|
112
|
+
path: '/',
|
|
113
|
+
message: `Schema compilation error: ${err.message}`,
|
|
114
|
+
expected: 'valid schema',
|
|
115
|
+
actual: 'invalid schema',
|
|
116
|
+
}],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const valid = validate(parsed);
|
|
121
|
+
if (valid) {
|
|
122
|
+
return { valid: true, errors: [] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
valid: false,
|
|
127
|
+
errors: formatErrors(validate.errors),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Validate a response body against the spec's schema for the given status code and content type.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} spec - Parsed OpenAPI spec
|
|
135
|
+
* @param {string} method - HTTP method (case-insensitive)
|
|
136
|
+
* @param {string} path - OpenAPI path template
|
|
137
|
+
* @param {number|string} statusCode - HTTP status code
|
|
138
|
+
* @param {string} responseBody - Response body string
|
|
139
|
+
* @param {string} responseContentType - Response content type
|
|
140
|
+
* @returns {{ valid: boolean|null, errors: Array }}
|
|
141
|
+
*/
|
|
142
|
+
export function validateResponse(spec, method, path, statusCode, responseBody, responseContentType) {
|
|
143
|
+
if (!spec || !spec.paths || !spec.paths[path]) {
|
|
144
|
+
return { valid: null, errors: [] };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const pathItem = spec.paths[path];
|
|
148
|
+
const operation = pathItem[method.toLowerCase()];
|
|
149
|
+
if (!operation || !operation.responses) {
|
|
150
|
+
return { valid: null, errors: [] };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const statusStr = String(statusCode);
|
|
154
|
+
|
|
155
|
+
// Try the exact status code first, then fallback to default
|
|
156
|
+
let responseDef = operation.responses[statusStr] || operation.responses.default;
|
|
157
|
+
if (!responseDef) {
|
|
158
|
+
return { valid: null, errors: [] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// OpenAPI 3.x: responses have content map
|
|
162
|
+
if (responseDef.content) {
|
|
163
|
+
const schema = findSchemaForContentType(responseDef.content, responseContentType);
|
|
164
|
+
if (!schema) {
|
|
165
|
+
return { valid: null, errors: [] };
|
|
166
|
+
}
|
|
167
|
+
return validateBody(schema, responseBody, responseContentType);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Swagger 2.0: responses have a direct schema property
|
|
171
|
+
if (responseDef.schema) {
|
|
172
|
+
return validateBody(responseDef.schema, responseBody, responseContentType || 'application/json');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { valid: null, errors: [] };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Find a schema in a content map for a given content type.
|
|
180
|
+
* Handles exact match and partial match (e.g., 'application/json' matches 'application/json; charset=utf-8').
|
|
181
|
+
*/
|
|
182
|
+
function findSchemaForContentType(content, responseContentType) {
|
|
183
|
+
if (!content || !responseContentType) return null;
|
|
184
|
+
|
|
185
|
+
// Normalize the content type: take just the media type part (before semicolon)
|
|
186
|
+
const normalizedType = responseContentType.split(';')[0].trim().toLowerCase();
|
|
187
|
+
|
|
188
|
+
for (const [ct, def] of Object.entries(content)) {
|
|
189
|
+
const normalizedCt = ct.split(';')[0].trim().toLowerCase();
|
|
190
|
+
if (normalizedCt === normalizedType) {
|
|
191
|
+
return def.schema || null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { join, extname } from 'node:path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
const EXCLUDED_DIRS = new Set([
|
|
6
|
+
'node_modules', '.git', 'vendor', 'dist', 'build', '__pycache__', '.venv', 'target'
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
const ROOT_PRIORITY = ['openapi.yaml', 'openapi.yml', 'openapi.json'];
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if a file is a valid OpenAPI/Swagger spec by examining its top-level keys.
|
|
13
|
+
*/
|
|
14
|
+
async function isSpecFile(filePath) {
|
|
15
|
+
try {
|
|
16
|
+
const ext = extname(filePath).toLowerCase();
|
|
17
|
+
if (!['.yaml', '.yml', '.json'].includes(ext)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const content = await readFile(filePath, 'utf-8');
|
|
22
|
+
let parsed;
|
|
23
|
+
|
|
24
|
+
if (ext === '.json') {
|
|
25
|
+
parsed = JSON.parse(content);
|
|
26
|
+
} else {
|
|
27
|
+
parsed = yaml.load(content);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (parsed && typeof parsed === 'object') {
|
|
31
|
+
return ('openapi' in parsed) || ('swagger' in parsed);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return false;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if a directory name should be excluded from recursive search.
|
|
42
|
+
*/
|
|
43
|
+
function isExcludedDir(name) {
|
|
44
|
+
if (EXCLUDED_DIRS.has(name)) return true;
|
|
45
|
+
if (name.startsWith('.')) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Discover an OpenAPI spec file in a directory.
|
|
51
|
+
*
|
|
52
|
+
* Priority:
|
|
53
|
+
* 1. openapi.yaml > openapi.yml > openapi.json in the project root
|
|
54
|
+
* 2. Recursive BFS, alphabetical order, excluding certain directories
|
|
55
|
+
*
|
|
56
|
+
* @param {string} projectDir - The project directory to search in
|
|
57
|
+
* @returns {Promise<{specPath: string, specFile: string} | null>}
|
|
58
|
+
*/
|
|
59
|
+
export async function discoverSpec(projectDir) {
|
|
60
|
+
// Phase 1: Check root priority files
|
|
61
|
+
for (const fileName of ROOT_PRIORITY) {
|
|
62
|
+
const filePath = join(projectDir, fileName);
|
|
63
|
+
if (await isSpecFile(filePath)) {
|
|
64
|
+
return { specPath: filePath, specFile: fileName };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Phase 2: Recursive BFS
|
|
69
|
+
const queue = [projectDir];
|
|
70
|
+
|
|
71
|
+
while (queue.length > 0) {
|
|
72
|
+
const currentDir = queue.shift();
|
|
73
|
+
let entries;
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
entries = await readdir(currentDir, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Sort entries alphabetically for deterministic order
|
|
82
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
83
|
+
|
|
84
|
+
const subdirs = [];
|
|
85
|
+
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const fullPath = join(currentDir, entry.name);
|
|
88
|
+
|
|
89
|
+
if (entry.isFile()) {
|
|
90
|
+
// Skip root priority files if we're in the root dir (already checked)
|
|
91
|
+
if (currentDir === projectDir && ROOT_PRIORITY.includes(entry.name)) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (await isSpecFile(fullPath)) {
|
|
96
|
+
const relative = fullPath.slice(projectDir.length + 1);
|
|
97
|
+
return { specPath: fullPath, specFile: relative };
|
|
98
|
+
}
|
|
99
|
+
} else if (entry.isDirectory() && !isExcludedDir(entry.name)) {
|
|
100
|
+
subdirs.push(fullPath);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Add subdirs to queue (they're already sorted)
|
|
105
|
+
queue.push(...subdirs);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import SwaggerParser from '@apidevtools/swagger-parser';
|
|
2
|
+
|
|
3
|
+
const HTTP_METHODS = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse and dereference an OpenAPI spec file.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} specPath - Absolute path to the spec file
|
|
9
|
+
* @returns {Promise<object>} - Parsed and dereferenced spec object
|
|
10
|
+
*/
|
|
11
|
+
export async function parseSpec(specPath) {
|
|
12
|
+
const spec = await SwaggerParser.dereference(specPath);
|
|
13
|
+
return spec;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract a lean endpoint list from a parsed spec.
|
|
18
|
+
*
|
|
19
|
+
* @param {object} spec - Parsed OpenAPI spec
|
|
20
|
+
* @returns {Array<{method: string, path: string, summary: string, tags: string[], deprecated: boolean}>}
|
|
21
|
+
*/
|
|
22
|
+
export function extractEndpoints(spec) {
|
|
23
|
+
const endpoints = [];
|
|
24
|
+
|
|
25
|
+
if (!spec || !spec.paths) {
|
|
26
|
+
return endpoints;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
|
30
|
+
if (!pathItem || typeof pathItem !== 'object') continue;
|
|
31
|
+
|
|
32
|
+
for (const method of HTTP_METHODS) {
|
|
33
|
+
const operation = pathItem[method];
|
|
34
|
+
if (!operation) continue;
|
|
35
|
+
|
|
36
|
+
endpoints.push({
|
|
37
|
+
method: method.toUpperCase(),
|
|
38
|
+
path,
|
|
39
|
+
summary: operation.summary || '',
|
|
40
|
+
tags: operation.tags || [],
|
|
41
|
+
deprecated: operation.deprecated || false,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return endpoints;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get full details for a single endpoint.
|
|
51
|
+
* Merges path-level and operation-level parameters (operation params override
|
|
52
|
+
* path params with the same name+in combination).
|
|
53
|
+
*
|
|
54
|
+
* @param {object} spec - Parsed OpenAPI spec
|
|
55
|
+
* @param {string} method - HTTP method (case-insensitive)
|
|
56
|
+
* @param {string} path - OpenAPI path template
|
|
57
|
+
* @returns {object|null} - Full endpoint details or null if not found
|
|
58
|
+
*/
|
|
59
|
+
export function getEndpointDetails(spec, method, path) {
|
|
60
|
+
if (!spec || !spec.paths || !spec.paths[path]) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const pathItem = spec.paths[path];
|
|
65
|
+
const lowerMethod = method.toLowerCase();
|
|
66
|
+
const operation = pathItem[lowerMethod];
|
|
67
|
+
|
|
68
|
+
if (!operation) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Merge parameters: path-level first, then operation-level overrides
|
|
73
|
+
const pathParams = pathItem.parameters || [];
|
|
74
|
+
const opParams = operation.parameters || [];
|
|
75
|
+
|
|
76
|
+
// Build a map keyed by name+in, path-level first, operation overrides
|
|
77
|
+
const paramMap = new Map();
|
|
78
|
+
for (const param of pathParams) {
|
|
79
|
+
const key = `${param.name}:${param.in}`;
|
|
80
|
+
paramMap.set(key, { ...param });
|
|
81
|
+
}
|
|
82
|
+
for (const param of opParams) {
|
|
83
|
+
const key = `${param.name}:${param.in}`;
|
|
84
|
+
paramMap.set(key, { ...param });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const mergedParams = Array.from(paramMap.values());
|
|
88
|
+
|
|
89
|
+
// Build request_body
|
|
90
|
+
let requestBody = null;
|
|
91
|
+
if (operation.requestBody) {
|
|
92
|
+
requestBody = {
|
|
93
|
+
required: operation.requestBody.required || false,
|
|
94
|
+
content: operation.requestBody.content || {},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Build responses
|
|
99
|
+
const responses = {};
|
|
100
|
+
if (operation.responses) {
|
|
101
|
+
for (const [statusCode, responseDef] of Object.entries(operation.responses)) {
|
|
102
|
+
responses[statusCode] = {
|
|
103
|
+
description: responseDef.description || '',
|
|
104
|
+
};
|
|
105
|
+
if (responseDef.content) {
|
|
106
|
+
responses[statusCode].content = responseDef.content;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Resolve servers: operation-level > path-level > spec-level
|
|
112
|
+
let servers = [];
|
|
113
|
+
if (operation.servers && operation.servers.length > 0) {
|
|
114
|
+
servers = operation.servers;
|
|
115
|
+
} else if (pathItem.servers && pathItem.servers.length > 0) {
|
|
116
|
+
servers = pathItem.servers;
|
|
117
|
+
} else if (spec.servers && spec.servers.length > 0) {
|
|
118
|
+
servers = spec.servers;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// For Swagger 2.0, construct servers from host + basePath + schemes
|
|
122
|
+
if (servers.length === 0 && spec.swagger && spec.host) {
|
|
123
|
+
const scheme = (spec.schemes && spec.schemes[0]) || 'https';
|
|
124
|
+
const basePath = spec.basePath || '';
|
|
125
|
+
servers = [{
|
|
126
|
+
url: `${scheme}://${spec.host}${basePath}`,
|
|
127
|
+
description: 'Default server',
|
|
128
|
+
}];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
method: method.toUpperCase(),
|
|
133
|
+
path,
|
|
134
|
+
summary: operation.summary || '',
|
|
135
|
+
description: operation.description || '',
|
|
136
|
+
tags: operation.tags || [],
|
|
137
|
+
deprecated: operation.deprecated || false,
|
|
138
|
+
parameters: mergedParams,
|
|
139
|
+
request_body: requestBody,
|
|
140
|
+
responses,
|
|
141
|
+
servers,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get all external file paths referenced via $ref in the spec.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} specPath - Absolute path to the spec file
|
|
149
|
+
* @returns {Promise<string[]>} - Array of external file paths
|
|
150
|
+
*/
|
|
151
|
+
export async function getRefDeps(specPath) {
|
|
152
|
+
const parser = new SwaggerParser();
|
|
153
|
+
await parser.resolve(specPath);
|
|
154
|
+
const allPaths = parser.$refs.paths();
|
|
155
|
+
|
|
156
|
+
// Filter out the spec file itself and return only external refs.
|
|
157
|
+
// $refs.paths() returns file URLs or absolute paths depending on platform.
|
|
158
|
+
return allPaths.filter(p => {
|
|
159
|
+
let normalized = p;
|
|
160
|
+
let specNormalized = specPath;
|
|
161
|
+
|
|
162
|
+
// Handle file:// URLs
|
|
163
|
+
if (p.startsWith('file://')) {
|
|
164
|
+
try { normalized = new URL(p).pathname; } catch { /* keep as-is */ }
|
|
165
|
+
}
|
|
166
|
+
if (specPath.startsWith('file://')) {
|
|
167
|
+
try { specNormalized = new URL(specPath).pathname; } catch { /* keep as-is */ }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return normalized !== specNormalized;
|
|
171
|
+
});
|
|
172
|
+
}
|
package/lib/index.cjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// CJS compatibility wrapper for ESM-only core modules.
|
|
4
|
+
//
|
|
5
|
+
// Usage in CommonJS projects:
|
|
6
|
+
//
|
|
7
|
+
// const apigrip = await require('apigrip');
|
|
8
|
+
// const { spec, endpoints } = await apigrip.loadSpec('./my-api/');
|
|
9
|
+
//
|
|
10
|
+
// Or with .then():
|
|
11
|
+
//
|
|
12
|
+
// require('apigrip').then(({ loadSpec, send }) => {
|
|
13
|
+
// // ...
|
|
14
|
+
// });
|
|
15
|
+
|
|
16
|
+
module.exports = import('./index.js');
|