Skip to content

Commit fe1177d

Browse files
committed
feat: api-devtools 구현
0 parents  commit fe1177d

18 files changed

Lines changed: 1725 additions & 0 deletions

.eslintrc.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
module.exports = {
2+
parser: '@typescript-eslint/parser',
3+
extends: [
4+
'eslint:recommended',
5+
'plugin:@typescript-eslint/recommended',
6+
'plugin:react/recommended',
7+
'prettier',
8+
],
9+
plugins: ['@typescript-eslint', 'react'],
10+
parserOptions: {
11+
ecmaVersion: 2022,
12+
sourceType: 'module',
13+
ecmaFeatures: {
14+
jsx: true,
15+
},
16+
},
17+
settings: {
18+
react: {
19+
version: '18.2',
20+
},
21+
},
22+
rules: {
23+
'@typescript-eslint/no-explicit-any': 'warn',
24+
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
25+
'react/react-in-jsx-scope': 'off',
26+
'react/prop-types': 'off',
27+
},
28+
env: {
29+
node: true,
30+
es2022: true,
31+
},
32+
};

.github/workflows/ci.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main, develop]
6+
pull_request:
7+
branches: [main, develop]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
strategy:
14+
matrix:
15+
node-version: [18.x, 20.x]
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Setup Node.js ${{ matrix.node-version }}
21+
uses: actions/setup-node@v4
22+
with:
23+
node-version: ${{ matrix.node-version }}
24+
cache: 'npm'
25+
26+
- name: Install dependencies
27+
run: npm ci
28+
29+
- name: Lint
30+
run: npm run lint
31+
32+
- name: Type check
33+
run: npx tsc --noEmit
34+
35+
- name: Build
36+
run: npm run build
37+
38+
- name: Test
39+
run: npm test

.gitignore

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Dependencies
2+
node_modules/
3+
package-lock.json
4+
yarn.lock
5+
pnpm-lock.yaml
6+
7+
# Build output
8+
dist/
9+
*.tsbuildinfo
10+
11+
# Environment
12+
.env
13+
.env.local
14+
.env.*.local
15+
16+
# IDE
17+
.vscode/
18+
.idea/
19+
*.swp
20+
*.swo
21+
*~
22+
23+
# OS
24+
.DS_Store
25+
Thumbs.db
26+
27+
# Logs
28+
logs/
29+
*.log
30+
npm-debug.log*
31+
32+
# Testing
33+
coverage/
34+
.nyc_output/
35+
36+
# Temporary
37+
tmp/
38+
temp/
39+
*.tmp

.prettierrc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"semi": true,
3+
"trailingComma": "es5",
4+
"singleQuote": true,
5+
"printWidth": 100,
6+
"tabWidth": 2,
7+
"useTabs": false,
8+
"arrowParens": "avoid"
9+
}

package.json

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
{
2+
"name": "@seyun31/api-devtools",
3+
"version": "1.0.0",
4+
"description": "API 테스트 & 디버깅용 CLI 개발 도구",
5+
"main": "dist/index.js",
6+
"type": "module",
7+
"bin": {
8+
"api-devtools": "./dist/cli.js"
9+
},
10+
"scripts": {
11+
"dev": "tsx src/cli.ts",
12+
"build": "tsc && chmod +x dist/cli.js",
13+
"start": "node dist/cli.js",
14+
"lint": "eslint src --ext .ts,.tsx",
15+
"lint:fix": "eslint src --ext .ts,.tsx --fix",
16+
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
17+
"test": "vitest",
18+
"test:ui": "vitest --ui"
19+
},
20+
"keywords": [
21+
"api",
22+
"devtools",
23+
"debugging",
24+
"testing",
25+
"cli",
26+
"tui",
27+
"http",
28+
"proxy",
29+
"mock",
30+
"korean"
31+
],
32+
"author": "seyun31",
33+
"license": "MIT",
34+
"dependencies": {
35+
"chalk": "^5.3.0",
36+
"clipboardy": "^4.0.0",
37+
"commander": "^11.1.0",
38+
"express": "^4.18.2",
39+
"http-proxy": "^1.18.1",
40+
"ink": "^4.4.1",
41+
"ink-select-input": "^5.0.0",
42+
"ink-spinner": "^5.0.0",
43+
"ink-table": "^3.1.0",
44+
"ink-text-input": "^5.0.1",
45+
"inquirer": "^9.3.8",
46+
"react": "^18.2.0"
47+
},
48+
"devDependencies": {
49+
"@types/express": "^4.17.21",
50+
"@types/http-proxy": "^1.17.14",
51+
"@types/inquirer": "^9.0.9",
52+
"@types/node": "^20.10.0",
53+
"@types/react": "^18.2.0",
54+
"@typescript-eslint/eslint-plugin": "^6.13.0",
55+
"@typescript-eslint/parser": "^6.13.0",
56+
"@vitest/ui": "^1.0.4",
57+
"eslint": "^8.55.0",
58+
"eslint-config-prettier": "^9.1.0",
59+
"eslint-plugin-react": "^7.33.2",
60+
"prettier": "^3.1.0",
61+
"tsx": "^4.7.0",
62+
"typescript": "^5.3.3",
63+
"vitest": "^1.0.4"
64+
},
65+
"engines": {
66+
"node": ">=18.0.0"
67+
},
68+
"publishConfig": {
69+
"access": "public"
70+
}
71+
}

src/cli.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#!/usr/bin/env node
2+
3+
import { Command } from 'commander';
4+
import { startDevTools, sendRequest, runSavedRequest, listRequests } from './index.js';
5+
import { startInteractive } from './interactive.js';
6+
7+
const program = new Command();
8+
9+
program
10+
.name('api-devtools')
11+
.description('API 테스트 & 디버깅용 CLI 개발 도구')
12+
.version('1.0.0')
13+
.action(async () => {
14+
// 인자 없이 실행하면 interactive 모드
15+
await startInteractive();
16+
});
17+
18+
// GET 요청
19+
program
20+
.command('get <url>')
21+
.description('GET 요청을 보냅니다')
22+
.option('-H, --header <header...>', '헤더 추가 (예: "Authorization: Bearer token")')
23+
.action(async (url, options) => {
24+
const headers = parseHeaders(options.header);
25+
await sendRequest('GET', url, { headers });
26+
});
27+
28+
// POST 요청
29+
program
30+
.command('post <url>')
31+
.description('POST 요청을 보냅니다')
32+
.option('-d, --data <data>', '요청 본문 (JSON)')
33+
.option('-H, --header <header...>', '헤더 추가')
34+
.action(async (url, options) => {
35+
const headers = parseHeaders(options.header);
36+
const body = options.data ? JSON.parse(options.data) : undefined;
37+
await sendRequest('POST', url, { headers, body });
38+
});
39+
40+
// PUT 요청
41+
program
42+
.command('put <url>')
43+
.description('PUT 요청을 보냅니다')
44+
.option('-d, --data <data>', '요청 본문 (JSON)')
45+
.option('-H, --header <header...>', '헤더 추가')
46+
.action(async (url, options) => {
47+
const headers = parseHeaders(options.header);
48+
const body = options.data ? JSON.parse(options.data) : undefined;
49+
await sendRequest('PUT', url, { headers, body });
50+
});
51+
52+
// DELETE 요청
53+
program
54+
.command('delete <url>')
55+
.description('DELETE 요청을 보냅니다')
56+
.option('-H, --header <header...>', '헤더 추가')
57+
.action(async (url, options) => {
58+
const headers = parseHeaders(options.header);
59+
await sendRequest('DELETE', url, { headers });
60+
});
61+
62+
// 저장된 요청 실행 또는 URL 실행
63+
program
64+
.command('run <nameOrUrl>')
65+
.description('저장된 요청을 실행하거나 URL로 GET 요청을 보냅니다')
66+
.action(async nameOrUrl => {
67+
await runSavedRequest(nameOrUrl);
68+
});
69+
70+
// 저장된 요청 목록
71+
program
72+
.command('list')
73+
.description('저장된 요청 목록을 표시합니다')
74+
.action(async () => {
75+
await listRequests();
76+
});
77+
78+
// 프록시 모드
79+
program
80+
.command('proxy')
81+
.description('프록시 모드로 API DevTools를 시작합니다')
82+
.option('-p, --port <port>', '프록시 서버 포트', '8888')
83+
.option('-t, --target <url>', '프록시 타겟 URL', '')
84+
.action(async options => {
85+
await startDevTools({
86+
port: parseInt(options.port),
87+
target: options.target,
88+
});
89+
});
90+
91+
// 헤더 파싱 헬퍼
92+
function parseHeaders(headerArray?: string[]): Record<string, string> | undefined {
93+
if (!headerArray) return undefined;
94+
95+
const headers: Record<string, string> = {};
96+
for (const header of headerArray) {
97+
const [key, ...valueParts] = header.split(':');
98+
if (key && valueParts.length > 0) {
99+
headers[key.trim()] = valueParts.join(':').trim();
100+
}
101+
}
102+
return headers;
103+
}
104+
105+
program.parse();

src/components/App.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { Box, Text, useInput, useApp } from 'ink';
3+
import { NetworkTable } from './NetworkTable.js';
4+
import { DetailView } from './DetailView.js';
5+
import type { CapturedRequest } from '../types/index.js';
6+
import { messages } from '../utils/messages.js';
7+
8+
interface AppProps {
9+
port: number;
10+
target?: string;
11+
requests: CapturedRequest[];
12+
onExit: () => void;
13+
}
14+
15+
export function App({ port, target, requests, onExit }: AppProps) {
16+
const { exit } = useApp();
17+
const [selectedIndex, setSelectedIndex] = useState(0);
18+
const [showDetail, setShowDetail] = useState(false);
19+
20+
// 키보드 입력 처리
21+
useInput((input, key) => {
22+
if (input === 'q' || (key.ctrl && input === 'c')) {
23+
onExit();
24+
exit();
25+
}
26+
27+
if (key.upArrow) {
28+
setSelectedIndex(prev => Math.max(0, prev - 1));
29+
}
30+
31+
if (key.downArrow) {
32+
setSelectedIndex(prev => Math.min(requests.length - 1, prev + 1));
33+
}
34+
35+
if (key.return) {
36+
setShowDetail(!showDetail);
37+
}
38+
});
39+
40+
return (
41+
<Box flexDirection="column" padding={1}>
42+
{/* 헤더 */}
43+
<Box marginBottom={1} flexDirection="column">
44+
<Text bold color="cyan">
45+
{messages.app.title}
46+
</Text>
47+
<Text>{messages.app.listening(port)}</Text>
48+
{target && <Text>{messages.app.proxy(`localhost:${port}`, target)}</Text>}
49+
</Box>
50+
51+
{/* 네트워크 테이블 */}
52+
<Box marginBottom={1} flexDirection="column">
53+
<Text bold>{messages.network.title}</Text>
54+
{requests.length === 0 ? (
55+
<Text color="gray">{messages.network.noRequests}</Text>
56+
) : (
57+
<NetworkTable requests={requests} selectedIndex={selectedIndex} />
58+
)}
59+
</Box>
60+
61+
{/* 상세 정보 */}
62+
{showDetail && requests[selectedIndex] && (
63+
<Box marginTop={1} flexDirection="column">
64+
<Text bold>{messages.details.title}</Text>
65+
<DetailView request={requests[selectedIndex]} />
66+
</Box>
67+
)}
68+
69+
{/* 단축키 도움말 */}
70+
<Box marginTop={1} borderStyle="single" borderColor="gray" padding={1}>
71+
<Text dimColor>
72+
{messages.keyboard.shortcuts.upDown} | {messages.keyboard.shortcuts.enter} |{' '}
73+
{messages.keyboard.shortcuts.q}
74+
</Text>
75+
</Box>
76+
</Box>
77+
);
78+
}

0 commit comments

Comments
 (0)