post-api-sync 0.1.1

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 ADDED
@@ -0,0 +1,110 @@
1
+ # api-sync
2
+
3
+ Sync your API code directly to Postman and Insomnia collections.
4
+
5
+ `api-sync` extracts endpoint definitions, parameters, and validation schemas (Zod, Class Validator) from your Hono, Express, or NestJS code and generates ready-to-use collections. It can also push changes directly to Postman Cloud.
6
+
7
+ ## Features
8
+
9
+ - 🔍 **Auto-Extraction**: Scans your codebase for API routes and definitions.
10
+ - 🛠 **Framework Support**:
11
+ - **Hono**: Extract routes and `zValidator` schemas.
12
+ - **Express**: Extract routes and validation middleware.
13
+ - **NestJS**: Extract Controllers, DTOs, and `class-validator` decorators.
14
+ - 📦 **Rich Collections**: Generates Postman and Insomnia collections with request bodies, query parameters, and examples.
15
+ - ☁️ **Live Sync**: Push collections directly to the Postman Cloud API.
16
+ - 👀 **Watch Mode**: Automatically sync changes as you code.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ npm install -g api-sync
22
+ # or use via npx
23
+ npx api-sync --help
24
+ ```
25
+
26
+ ## Quick Start
27
+
28
+ 1. **Initialize configuration**:
29
+ ```bash
30
+ npx api-sync init
31
+ ```
32
+ This will create an `api-sync.config.js` file in your project root.
33
+
34
+ 2. **Run extraction**:
35
+ ```bash
36
+ npx api-sync sync
37
+ ```
38
+
39
+ 3. **Watch for changes**:
40
+ ```bash
41
+ npx api-sync watch
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ The `api-sync.config.js` file allows you to customize the tool's behavior:
47
+
48
+ ```javascript
49
+ module.exports = {
50
+ // 'hono', 'express', 'nestjs', or 'auto'
51
+ framework: 'auto',
52
+
53
+ sources: {
54
+ // Glob patterns to include
55
+ include: ['src/**/*.ts', 'src/**/*.js'],
56
+ // Glob patterns to exclude
57
+ exclude: ['**/*.test.ts'],
58
+ // Base URL for variables in collections
59
+ baseUrl: 'http://localhost:3000'
60
+ },
61
+
62
+ output: {
63
+ postman: {
64
+ enabled: true,
65
+ outputPath: './postman_collection.json',
66
+ // Optional: Default API Key and Collection ID for Cloud Sync
67
+ apiKey: process.env.POSTMAN_API_KEY,
68
+ collectionId: process.env.POSTMAN_COLLECTION_ID
69
+ },
70
+ insomnia: {
71
+ enabled: true,
72
+ outputPath: './insomnia_collection.json'
73
+ }
74
+ }
75
+ };
76
+ ```
77
+
78
+ ## Postman Cloud Sync
79
+
80
+ You can push your generated collection directly to Postman without manual importing.
81
+
82
+ 1. Get your **Postman API Key** from your [Account Settings](https://postman.co/settings/me/api-keys).
83
+ 2. Get your **Collection UID** (Right-click collection -> Info).
84
+ 3. Run the sync command:
85
+
86
+ ```bash
87
+ npx api-sync sync --postman-key <YOUR_KEY> --postman-id <COLLECTION_UID>
88
+ ```
89
+
90
+ Or set them in your config/environment variables to use with `watch` mode.
91
+
92
+ ## Supported Patterns
93
+
94
+ ### Hono
95
+ - `zValidator('json', schema)` -> Request Body
96
+ - `zValidator('query', schema)` -> Query Parameters
97
+
98
+ ### Express
99
+ - Router methods: `router.get`, `router.post`, etc.
100
+ - Validation middleware extraction (mapped to Zod schemas).
101
+
102
+ ### NestJS
103
+ - `@Controller`, `@Get`, `@Post`, etc.
104
+ - DTOs in `@Body()` and `@Query()`.
105
+ - `class-validator` decorators: `@IsString`, `@IsInt`, `@Min`, etc.
106
+ - `@ApiProperty({ example: ... })` for example values.
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { program } = require('commander');
4
+ const { initConfig } = require('../src/init');
5
+ const { syncOnce } = require('../src/sync');
6
+ const { watchMode } = require('../src/watch');
7
+
8
+ program
9
+ .name('api-sync')
10
+ .description('Sync Postman and Insomnia collections from API code');
11
+
12
+ program
13
+ .command('init')
14
+ .description('Create api-sync.config.js')
15
+ .option('--cwd <path>', 'Project root for config')
16
+ .action(async (opts) => {
17
+ await initConfig({ baseDir: opts.cwd });
18
+ });
19
+
20
+ program
21
+ .command('sync')
22
+ .description('One-time collection sync')
23
+ .option('-c, --config <path>', 'Path to config file OR project directory')
24
+ .option('--cwd <path>', 'Project root (used for config + globs)')
25
+ .option('--postman-key <key>', 'Postman API Key')
26
+ .option('--postman-id <id>', 'Postman Collection UID')
27
+ .action(async (opts) => {
28
+ await syncOnce({
29
+ configPath: opts.config,
30
+ baseDir: opts.cwd,
31
+ postmanKey: opts.postmanKey,
32
+ postmanId: opts.postmanId
33
+ });
34
+ });
35
+
36
+ program
37
+ .command('watch')
38
+ .description('Watch for changes and sync collections')
39
+ .option('-c, --config <path>', 'Path to config file OR project directory')
40
+ .option('--cwd <path>', 'Project root (used for config + globs)')
41
+ .option('--postman-key <key>', 'Postman API Key')
42
+ .option('--postman-id <id>', 'Postman Collection UID')
43
+ .action(async (opts) => {
44
+ await watchMode({
45
+ configPath: opts.config,
46
+ baseDir: opts.cwd,
47
+ postmanKey: opts.postmanKey,
48
+ postmanId: opts.postmanId
49
+ });
50
+ });
51
+
52
+ program.parseAsync(process.argv);
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "post-api-sync",
3
+ "version": "0.1.1",
4
+ "description": "Sync Postman and Insomnia collections from API code",
5
+ "type": "commonjs",
6
+ "bin": {
7
+ "api-sync": "bin/api-sync.js"
8
+ },
9
+ "scripts": {
10
+ "sync": "node bin/api-sync.js sync",
11
+ "watch": "node bin/api-sync.js watch",
12
+ "init": "node bin/api-sync.js init"
13
+ },
14
+ "dependencies": {
15
+ "@babel/parser": "^7.24.0",
16
+ "@babel/traverse": "^7.24.0",
17
+ "chokidar": "^3.6.0",
18
+ "commander": "^12.0.0",
19
+ "fast-glob": "^3.3.2",
20
+ "fs-extra": "^11.2.0",
21
+ "inquirer": "^9.2.0",
22
+ "kleur": "^4.1.5",
23
+ "nanoid": "^5.0.7"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "keywords": [
29
+ "api",
30
+ "sync",
31
+ "postman",
32
+ "insomnia",
33
+ "hono",
34
+ "express",
35
+ "nestjs",
36
+ "documentation",
37
+ "automation"
38
+ ],
39
+ "author": "",
40
+ "files": [
41
+ "bin",
42
+ "src",
43
+ "README.md",
44
+ "package.json"
45
+ ],
46
+ "license": "MIT"
47
+ }
@@ -0,0 +1,81 @@
1
+ const { nanoid } = require('nanoid');
2
+
3
+ function buildInsomniaCollection(endpoints, config) {
4
+ const baseUrl = config.sources.baseUrl || 'http://localhost:3000';
5
+ const workspaceId = `wrk_${nanoid(10)}`;
6
+ const envId = `env_${nanoid(10)}`;
7
+
8
+ const resources = [
9
+ {
10
+ _id: workspaceId,
11
+ _type: 'workspace',
12
+ name: (config.output && config.output.insomnia && config.output.insomnia.workspaceName) || 'API Workspace'
13
+ },
14
+ {
15
+ _id: envId,
16
+ _type: 'environment',
17
+ parentId: workspaceId,
18
+ name: 'Base Environment',
19
+ data: { baseUrl }
20
+ }
21
+ ];
22
+
23
+ for (const endpoint of endpoints) {
24
+ resources.push(buildRequest(endpoint, workspaceId));
25
+ }
26
+
27
+ return {
28
+ _type: 'export',
29
+ __export_format: 4,
30
+ __export_date: new Date().toISOString(),
31
+ __export_source: 'api-sync',
32
+ resources
33
+ };
34
+ }
35
+
36
+ function buildRequest(endpoint, workspaceId) {
37
+ const bodySchema = endpoint.parameters && endpoint.parameters.body ? endpoint.parameters.body : null;
38
+ const hasBody = !!bodySchema;
39
+ const example = hasBody ? exampleFromSchema(bodySchema) : null;
40
+
41
+ return {
42
+ _id: `req_${nanoid(10)}`,
43
+ _type: 'request',
44
+ parentId: workspaceId,
45
+ name: endpoint.description || `${endpoint.method} ${endpoint.path}`,
46
+ method: endpoint.method,
47
+ url: `{{ _.baseUrl }}${endpoint.path}`,
48
+ headers: hasBody ? [{ name: 'Content-Type', value: 'application/json' }] : [],
49
+ parameters: (endpoint.parameters && endpoint.parameters.query || []).map(q => ({
50
+ name: q.name,
51
+ value: '',
52
+ disabled: !q.required
53
+ })),
54
+ body: hasBody
55
+ ? {
56
+ mimeType: 'application/json',
57
+ text: JSON.stringify(example || {}, null, 2)
58
+ }
59
+ : {}
60
+ };
61
+ }
62
+
63
+ function exampleFromSchema(schema, depth = 0) {
64
+ if (!schema || depth > 3) return {};
65
+ if (schema.example !== undefined) return schema.example;
66
+ if (schema.type === 'string') return '';
67
+ if (schema.type === 'number') return 0;
68
+ if (schema.type === 'boolean') return false;
69
+ if (schema.type === 'array') return [exampleFromSchema(schema.items || {}, depth + 1)];
70
+ if (schema.type === 'object') {
71
+ const obj = {};
72
+ const props = schema.properties || {};
73
+ for (const key of Object.keys(props)) {
74
+ obj[key] = exampleFromSchema(props[key], depth + 1);
75
+ }
76
+ return obj;
77
+ }
78
+ return {};
79
+ }
80
+
81
+ module.exports = { buildInsomniaCollection };
@@ -0,0 +1,88 @@
1
+ const { nanoid } = require('nanoid');
2
+ const { toPostmanPath, splitPath, extractPathParams } = require('../utils');
3
+
4
+ function buildPostmanCollection(endpoints, config) {
5
+ const name = (config.output && config.output.postman && config.output.postman.collectionName) || 'API Collection';
6
+ const baseUrl = config.sources.baseUrl || 'http://localhost:3000';
7
+ const groupBy = (config.organization && config.organization.groupBy) || 'tags';
8
+
9
+ const items = groupBy === 'tags' ? buildTaggedItems(endpoints) : endpoints.map(buildItem);
10
+
11
+ return {
12
+ info: {
13
+ name,
14
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
15
+ _postman_id: nanoid()
16
+ },
17
+ item: items,
18
+ variable: [{ key: 'baseUrl', value: baseUrl }]
19
+ };
20
+ }
21
+
22
+ function buildTaggedItems(endpoints) {
23
+ const groups = new Map();
24
+ for (const endpoint of endpoints) {
25
+ const tags = endpoint.tags && endpoint.tags.length ? endpoint.tags : ['General'];
26
+ const tag = tags[0];
27
+ if (!groups.has(tag)) groups.set(tag, []);
28
+ groups.get(tag).push(buildItem(endpoint));
29
+ }
30
+ const items = [];
31
+ for (const [name, groupItems] of groups.entries()) {
32
+ items.push({ name, item: groupItems });
33
+ }
34
+ return items;
35
+ }
36
+
37
+ function buildItem(endpoint) {
38
+ const path = toPostmanPath(endpoint.path);
39
+ const pathParams = extractPathParams(path);
40
+ const queryParams = (endpoint.parameters && endpoint.parameters.query) || [];
41
+ const bodySchema = endpoint.parameters && endpoint.parameters.body ? endpoint.parameters.body : null;
42
+ const hasBody = !!bodySchema;
43
+
44
+ const example = hasBody ? exampleFromSchema(bodySchema) : null;
45
+
46
+ return {
47
+ name: endpoint.description || `${endpoint.method} ${endpoint.path}`,
48
+ request: {
49
+ method: endpoint.method,
50
+ header: hasBody ? [{ key: 'Content-Type', value: 'application/json' }] : [],
51
+ url: {
52
+ raw: `{{baseUrl}}${path}`,
53
+ host: ['{{baseUrl}}'],
54
+ path: splitPath(path),
55
+ query: queryParams.map((q) => ({ key: q.name, value: '', disabled: !q.required })),
56
+ variable: pathParams.map((p) => ({ key: p, value: '' }))
57
+ },
58
+ body: hasBody
59
+ ? {
60
+ mode: 'raw',
61
+ raw: JSON.stringify(example || {}, null, 2),
62
+ options: { raw: { language: 'json' } }
63
+ }
64
+ : undefined
65
+ },
66
+ response: []
67
+ };
68
+ }
69
+
70
+ function exampleFromSchema(schema, depth = 0) {
71
+ if (!schema || depth > 3) return {};
72
+ if (schema.example !== undefined) return schema.example;
73
+ if (schema.type === 'string') return '';
74
+ if (schema.type === 'number') return 0;
75
+ if (schema.type === 'boolean') return false;
76
+ if (schema.type === 'array') return [exampleFromSchema(schema.items || {}, depth + 1)];
77
+ if (schema.type === 'object') {
78
+ const obj = {};
79
+ const props = schema.properties || {};
80
+ for (const key of Object.keys(props)) {
81
+ obj[key] = exampleFromSchema(props[key], depth + 1);
82
+ }
83
+ return obj;
84
+ }
85
+ return {};
86
+ }
87
+
88
+ module.exports = { buildPostmanCollection };
package/src/config.js ADDED
@@ -0,0 +1,141 @@
1
+ const path = require('path');
2
+ const fs = require('fs-extra');
3
+
4
+ const ALWAYS_EXCLUDE = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts'];
5
+
6
+ const DEFAULT_CONFIG = {
7
+ framework: 'auto',
8
+ sources: {
9
+ include: ['src/**/*.{ts,js,tsx,jsx}'],
10
+ exclude: ['**/*.spec.ts', '**/*.test.ts', 'node_modules/**', 'dist/**', 'build/**'],
11
+ baseUrl: 'http://localhost:3000'
12
+ },
13
+ output: {
14
+ postman: {
15
+ enabled: true,
16
+ outputPath: './collections/postman-collection.json'
17
+ },
18
+ insomnia: {
19
+ enabled: true,
20
+ outputPath: './collections/insomnia-collection.json'
21
+ }
22
+ },
23
+ watch: {
24
+ enabled: true,
25
+ debounce: 300
26
+ },
27
+ merge: {
28
+ markDeprecated: true
29
+ },
30
+ organization: {
31
+ groupBy: 'tags'
32
+ }
33
+ };
34
+
35
+ async function resolveConfigPath(configPath, baseDir) {
36
+ let cwd = baseDir ? path.resolve(baseDir) : process.cwd();
37
+
38
+ if (configPath) {
39
+ const abs = path.isAbsolute(configPath) ? configPath : path.resolve(cwd, configPath);
40
+ if (await fs.pathExists(abs)) {
41
+ const stat = await fs.stat(abs);
42
+ if (stat.isDirectory()) {
43
+ cwd = abs;
44
+ return { path: path.resolve(cwd, 'api-sync.config.js'), baseDir: cwd };
45
+ }
46
+ return { path: abs, baseDir: path.dirname(abs) };
47
+ }
48
+ if (!path.extname(abs)) {
49
+ cwd = abs;
50
+ return { path: path.resolve(cwd, 'api-sync.config.js'), baseDir: cwd };
51
+ }
52
+ return { path: abs, baseDir: cwd };
53
+ }
54
+
55
+ return { path: path.resolve(cwd, 'api-sync.config.js'), baseDir: cwd };
56
+ }
57
+
58
+ async function loadConfig(configPath, baseDir) {
59
+ const resolved = await resolveConfigPath(configPath, baseDir);
60
+ if (await fs.pathExists(resolved.path)) {
61
+ // eslint-disable-next-line global-require, import/no-dynamic-require
62
+ const userConfig = require(resolved.path);
63
+ return { config: mergeDeep(DEFAULT_CONFIG, userConfig), path: resolved.path, baseDir: resolved.baseDir };
64
+ }
65
+ return { config: DEFAULT_CONFIG, path: resolved.path, baseDir: resolved.baseDir };
66
+ }
67
+
68
+ function mergeDeep(base, override) {
69
+ if (Array.isArray(base) || Array.isArray(override)) {
70
+ return override === undefined ? base : override;
71
+ }
72
+ if (typeof base === 'object' && base && typeof override === 'object' && override) {
73
+ const out = { ...base };
74
+ for (const key of Object.keys(override)) {
75
+ out[key] = mergeDeep(base[key], override[key]);
76
+ }
77
+ return out;
78
+ }
79
+ return override === undefined ? base : override;
80
+ }
81
+
82
+ function ensureAbsolute(pathLike, baseDir) {
83
+ if (!pathLike) return pathLike;
84
+ if (path.isAbsolute(pathLike)) return pathLike;
85
+ const cwd = baseDir ? path.resolve(baseDir) : process.cwd();
86
+ return path.resolve(cwd, pathLike);
87
+ }
88
+
89
+ const GLOB_CHARS = /[*?[\]{}!]/;
90
+
91
+ function looksLikeGlob(pattern) {
92
+ return GLOB_CHARS.test(pattern);
93
+ }
94
+
95
+ function normalizePattern(entry, baseDir, isInclude) {
96
+ if (!entry) return [];
97
+ const trimmed = entry.trim();
98
+ if (!trimmed) return [];
99
+
100
+ if (looksLikeGlob(trimmed)) return [trimmed];
101
+
102
+ const cwd = baseDir ? path.resolve(baseDir) : process.cwd();
103
+ const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
104
+
105
+ if (fs.existsSync(abs)) {
106
+ const stat = fs.statSync(abs);
107
+ if (stat.isDirectory()) {
108
+ return [path.join(trimmed, isInclude ? '**/*.{ts,js,tsx,jsx}' : '**')];
109
+ }
110
+ return [trimmed];
111
+ }
112
+
113
+ // Treat bare extensions like "ts" or ".ts"
114
+ if (!trimmed.includes('/') && !trimmed.includes('\\')) {
115
+ const ext = trimmed.startsWith('.') ? trimmed.slice(1) : trimmed;
116
+ if (ext) return [isInclude ? `**/*.${ext}` : `**/*.${ext}`];
117
+ }
118
+
119
+ return [trimmed];
120
+ }
121
+
122
+ function normalizeIncludePatterns(patterns, baseDir) {
123
+ const list = Array.isArray(patterns) ? patterns : [patterns];
124
+ return list.flatMap((p) => normalizePattern(p, baseDir, true));
125
+ }
126
+
127
+ function normalizeExcludePatterns(patterns, baseDir) {
128
+ const list = Array.isArray(patterns) ? patterns : [patterns];
129
+ const normalized = list.flatMap((p) => normalizePattern(p, baseDir, false));
130
+ return Array.from(new Set([...normalized, ...ALWAYS_EXCLUDE]));
131
+ }
132
+
133
+ module.exports = {
134
+ DEFAULT_CONFIG,
135
+ resolveConfigPath,
136
+ loadConfig,
137
+ ensureAbsolute,
138
+ normalizeIncludePatterns,
139
+ normalizeExcludePatterns,
140
+ ALWAYS_EXCLUDE
141
+ };
@@ -0,0 +1,32 @@
1
+ const fs = require('fs-extra');
2
+ const parser = require('@babel/parser');
3
+
4
+ async function parseFile(filePath) {
5
+ const code = await fs.readFile(filePath, 'utf8');
6
+ const base = {
7
+ sourceType: 'module',
8
+ plugins: [
9
+ 'typescript',
10
+ 'classProperties',
11
+ 'classPrivateProperties',
12
+ 'classPrivateMethods',
13
+ 'jsx',
14
+ 'dynamicImport'
15
+ ]
16
+ };
17
+
18
+ try {
19
+ return parser.parse(code, {
20
+ ...base,
21
+ plugins: ['decorators-legacy', ...base.plugins]
22
+ });
23
+ } catch (err) {
24
+ // Fallback to stage-3 decorators if legacy parsing fails
25
+ return parser.parse(code, {
26
+ ...base,
27
+ plugins: [['decorators', { decoratorsBeforeExport: true }], ...base.plugins]
28
+ });
29
+ }
30
+ }
31
+
32
+ module.exports = { parseFile };