@kurly-growth/growthman 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.
Files changed (44) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/CLAUDE.md +58 -0
  3. package/README.md +23 -0
  4. package/app/api/endpoints/[id]/route.ts +63 -0
  5. package/app/api/endpoints/bulk/route.ts +23 -0
  6. package/app/api/endpoints/import/route.ts +52 -0
  7. package/app/api/endpoints/route.ts +39 -0
  8. package/app/api/endpoints/sync/route.ts +80 -0
  9. package/app/api/mock/[...slug]/route.ts +82 -0
  10. package/app/favicon.ico +0 -0
  11. package/app/globals.css +125 -0
  12. package/app/layout.tsx +36 -0
  13. package/app/page.tsx +184 -0
  14. package/bin/cli.js +28 -0
  15. package/components/endpoint-edit-dialog.tsx +181 -0
  16. package/components/endpoint-table.tsx +133 -0
  17. package/components/openapi-upload-dialog.tsx +196 -0
  18. package/components/ui/button.tsx +62 -0
  19. package/components/ui/checkbox.tsx +32 -0
  20. package/components/ui/dialog.tsx +143 -0
  21. package/components/ui/input.tsx +21 -0
  22. package/components/ui/label.tsx +24 -0
  23. package/components/ui/sonner.tsx +37 -0
  24. package/components/ui/table.tsx +116 -0
  25. package/components/ui/textarea.tsx +18 -0
  26. package/components.json +22 -0
  27. package/eslint.config.mjs +18 -0
  28. package/lib/openapi-parser.ts +270 -0
  29. package/lib/prisma.ts +9 -0
  30. package/lib/utils.ts +6 -0
  31. package/lib/validation.ts +19 -0
  32. package/next.config.ts +7 -0
  33. package/package.json +56 -0
  34. package/pnpm-workspace.yaml +4 -0
  35. package/postcss.config.mjs +7 -0
  36. package/prisma/schema.prisma +23 -0
  37. package/prisma/seed.ts +29 -0
  38. package/public/file.svg +1 -0
  39. package/public/globe.svg +1 -0
  40. package/public/next.svg +1 -0
  41. package/public/vercel.svg +1 -0
  42. package/public/window.svg +1 -0
  43. package/tsconfig.json +34 -0
  44. package/types/endpoint.ts +10 -0
package/app/page.tsx ADDED
@@ -0,0 +1,184 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect, useCallback } from 'react'
4
+ import { MockEndpoint } from '@/types/endpoint'
5
+ import { EndpointTable } from '@/components/endpoint-table'
6
+ import { EndpointEditDialog } from '@/components/endpoint-edit-dialog'
7
+ import { OpenAPIUploadDialog } from '@/components/openapi-upload-dialog'
8
+ import { Button } from '@/components/ui/button'
9
+ import { toast } from 'sonner'
10
+
11
+ export default function Home() {
12
+ const [endpoints, setEndpoints] = useState<MockEndpoint[]>([])
13
+ const [loading, setLoading] = useState(true)
14
+ const [editDialogOpen, setEditDialogOpen] = useState(false)
15
+ const [uploadDialogOpen, setUploadDialogOpen] = useState(false)
16
+ const [selectedEndpoint, setSelectedEndpoint] = useState<MockEndpoint | null>(null)
17
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
18
+
19
+ const fetchEndpoints = useCallback(async () => {
20
+ try {
21
+ const response = await fetch('/api/endpoints')
22
+ const data = await response.json()
23
+ setEndpoints(data)
24
+ } catch (error) {
25
+ console.error('Failed to fetch endpoints:', error)
26
+ } finally {
27
+ setLoading(false)
28
+ }
29
+ }, [])
30
+
31
+ useEffect(() => {
32
+ fetchEndpoints()
33
+ }, [fetchEndpoints])
34
+
35
+ const handleEdit = (endpoint: MockEndpoint) => {
36
+ setSelectedEndpoint(endpoint)
37
+ setEditDialogOpen(true)
38
+ }
39
+
40
+ const handleCreate = () => {
41
+ setSelectedEndpoint(null)
42
+ setEditDialogOpen(true)
43
+ }
44
+
45
+ const handleDelete = async (id: string) => {
46
+ if (!confirm('Are you sure you want to delete this endpoint?')) return
47
+
48
+ try {
49
+ const response = await fetch(`/api/endpoints/${id}`, { method: 'DELETE' })
50
+ if (!response.ok) throw new Error('Failed to delete')
51
+ setSelectedIds((prev) => {
52
+ const newSet = new Set(prev)
53
+ newSet.delete(id)
54
+ return newSet
55
+ })
56
+ toast.success('Endpoint deleted successfully')
57
+ fetchEndpoints()
58
+ } catch (error) {
59
+ console.error('Failed to delete endpoint:', error)
60
+ toast.error('Failed to delete endpoint')
61
+ }
62
+ }
63
+
64
+ const handleBulkDelete = async () => {
65
+ if (selectedIds.size === 0) return
66
+ if (!confirm(`Are you sure you want to delete ${selectedIds.size} endpoint(s)?`)) return
67
+
68
+ try {
69
+ const response = await fetch('/api/endpoints/bulk', {
70
+ method: 'DELETE',
71
+ headers: { 'Content-Type': 'application/json' },
72
+ body: JSON.stringify({ ids: Array.from(selectedIds) }),
73
+ })
74
+ if (!response.ok) throw new Error('Failed to delete')
75
+ const count = selectedIds.size
76
+ setSelectedIds(new Set())
77
+ toast.success(`${count} endpoint(s) deleted successfully`)
78
+ fetchEndpoints()
79
+ } catch (error) {
80
+ console.error('Failed to delete endpoints:', error)
81
+ toast.error('Failed to delete endpoints')
82
+ }
83
+ }
84
+
85
+ const handleSave = async (endpoint: Partial<MockEndpoint> & { id?: string }) => {
86
+ try {
87
+ const isUpdate = !!endpoint.id
88
+ const response = isUpdate
89
+ ? await fetch(`/api/endpoints/${endpoint.id}`, {
90
+ method: 'PUT',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify(endpoint),
93
+ })
94
+ : await fetch('/api/endpoints', {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify(endpoint),
98
+ })
99
+
100
+ if (!response.ok) {
101
+ const data = await response.json()
102
+ throw new Error(data.error || 'Failed to save')
103
+ }
104
+
105
+ toast.success(isUpdate ? 'Endpoint updated successfully' : 'Endpoint created successfully')
106
+ fetchEndpoints()
107
+ } catch (error) {
108
+ console.error('Failed to save endpoint:', error)
109
+ toast.error(error instanceof Error ? error.message : 'Failed to save endpoint')
110
+ }
111
+ }
112
+
113
+ return (
114
+ <div className="min-h-screen bg-gray-50">
115
+ <div className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
116
+ <div className="flex justify-between items-center mb-8">
117
+ <div>
118
+ <h1 className="text-3xl font-bold text-gray-900">Growthman</h1>
119
+ <p className="mt-1 text-gray-500">
120
+ Manage your mock API endpoints
121
+ </p>
122
+ </div>
123
+ <div className="flex gap-3">
124
+ <Button variant="outline" onClick={() => setUploadDialogOpen(true)}>
125
+ Import OpenAPI
126
+ </Button>
127
+ <Button onClick={handleCreate}>
128
+ + New Endpoint
129
+ </Button>
130
+ </div>
131
+ </div>
132
+
133
+ {selectedIds.size > 0 && (
134
+ <div className="mb-4 flex items-center gap-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
135
+ <span className="text-sm text-blue-800">
136
+ {selectedIds.size} endpoint(s) selected
137
+ </span>
138
+ <Button
139
+ variant="destructive"
140
+ size="sm"
141
+ onClick={handleBulkDelete}
142
+ >
143
+ Delete Selected
144
+ </Button>
145
+ <Button
146
+ variant="ghost"
147
+ size="sm"
148
+ onClick={() => setSelectedIds(new Set())}
149
+ >
150
+ Clear Selection
151
+ </Button>
152
+ </div>
153
+ )}
154
+
155
+ <div className="bg-white shadow rounded-lg">
156
+ {loading ? (
157
+ <div className="p-8 text-center text-gray-500">Loading...</div>
158
+ ) : (
159
+ <EndpointTable
160
+ endpoints={endpoints}
161
+ onEdit={handleEdit}
162
+ onDelete={handleDelete}
163
+ selectedIds={selectedIds}
164
+ onSelectionChange={setSelectedIds}
165
+ />
166
+ )}
167
+ </div>
168
+ </div>
169
+
170
+ <EndpointEditDialog
171
+ endpoint={selectedEndpoint}
172
+ open={editDialogOpen}
173
+ onOpenChange={setEditDialogOpen}
174
+ onSave={handleSave}
175
+ />
176
+
177
+ <OpenAPIUploadDialog
178
+ open={uploadDialogOpen}
179
+ onOpenChange={setUploadDialogOpen}
180
+ onImportComplete={fetchEndpoints}
181
+ />
182
+ </div>
183
+ )
184
+ }
package/bin/cli.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { execSync, spawn } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ const packageDir = path.resolve(__dirname, '..');
8
+ const dbPath = path.join(packageDir, 'prisma', 'dev.db');
9
+
10
+ // DB가 없으면 생성
11
+ if (!fs.existsSync(dbPath)) {
12
+ console.log('Initializing database...');
13
+ execSync('npx prisma generate && npx prisma db push', {
14
+ cwd: packageDir,
15
+ stdio: 'inherit',
16
+ });
17
+ }
18
+
19
+ // 서버 시작
20
+ console.log('Starting mock server at http://localhost:3000');
21
+ const server = spawn('npx', ['next', 'start'], {
22
+ cwd: packageDir,
23
+ stdio: 'inherit',
24
+ });
25
+
26
+ server.on('close', (code) => {
27
+ process.exit(code);
28
+ });
@@ -0,0 +1,181 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import dynamic from 'next/dynamic'
5
+ import { MockEndpoint } from '@/types/endpoint'
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DialogFooter,
12
+ } from '@/components/ui/dialog'
13
+ import { Button } from '@/components/ui/button'
14
+ import { Input } from '@/components/ui/input'
15
+ import { Label } from '@/components/ui/label'
16
+
17
+ const MonacoEditor = dynamic(() => import('@monaco-editor/react'), {
18
+ ssr: false,
19
+ loading: () => <div className="h-[400px] bg-gray-100 animate-pulse rounded" />,
20
+ })
21
+
22
+ interface EndpointEditDialogProps {
23
+ endpoint: MockEndpoint | null
24
+ open: boolean
25
+ onOpenChange: (open: boolean) => void
26
+ onSave: (endpoint: Partial<MockEndpoint> & { id?: string }) => void
27
+ }
28
+
29
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']
30
+
31
+ export function EndpointEditDialog({
32
+ endpoint,
33
+ open,
34
+ onOpenChange,
35
+ onSave,
36
+ }: EndpointEditDialogProps) {
37
+ const [path, setPath] = useState('')
38
+ const [method, setMethod] = useState('GET')
39
+ const [description, setDescription] = useState('')
40
+ const [statusCode, setStatusCode] = useState(200)
41
+ const [responseBody, setResponseBody] = useState('{}')
42
+ const [jsonError, setJsonError] = useState<string | null>(null)
43
+
44
+ useEffect(() => {
45
+ if (endpoint) {
46
+ setPath(endpoint.path)
47
+ setMethod(endpoint.method)
48
+ setDescription(endpoint.description || '')
49
+ setStatusCode(endpoint.statusCode)
50
+ setResponseBody(JSON.stringify(endpoint.responseBody, null, 2))
51
+ } else {
52
+ setPath('')
53
+ setMethod('GET')
54
+ setDescription('')
55
+ setStatusCode(200)
56
+ setResponseBody('{}')
57
+ }
58
+ setJsonError(null)
59
+ }, [endpoint, open])
60
+
61
+ const handleSave = () => {
62
+ try {
63
+ const parsedBody = JSON.parse(responseBody)
64
+ setJsonError(null)
65
+ onSave({
66
+ id: endpoint?.id,
67
+ path,
68
+ method,
69
+ description: description || null,
70
+ statusCode,
71
+ responseBody: parsedBody,
72
+ })
73
+ onOpenChange(false)
74
+ } catch {
75
+ setJsonError('Invalid JSON format')
76
+ }
77
+ }
78
+
79
+ return (
80
+ <Dialog open={open} onOpenChange={onOpenChange}>
81
+ <DialogContent className="sm:max-w-[70vw] h-[90vh] flex flex-col">
82
+ <DialogHeader>
83
+ <DialogTitle>
84
+ {endpoint ? 'Edit Endpoint' : 'Create New Endpoint'}
85
+ </DialogTitle>
86
+ </DialogHeader>
87
+
88
+ <div className="grid gap-4 py-4 flex-1 overflow-hidden">
89
+ <div className="flex items-center gap-4">
90
+ <Label htmlFor="method" className="w-28 text-right shrink-0">
91
+ Method
92
+ </Label>
93
+ <select
94
+ id="method"
95
+ value={method}
96
+ onChange={(e) => setMethod(e.target.value)}
97
+ className="flex-1 h-10 rounded-md border border-input bg-background px-3 py-2"
98
+ >
99
+ {HTTP_METHODS.map((m) => (
100
+ <option key={m} value={m}>
101
+ {m}
102
+ </option>
103
+ ))}
104
+ </select>
105
+ </div>
106
+
107
+ <div className="flex items-center gap-4">
108
+ <Label htmlFor="path" className="w-28 text-right shrink-0">
109
+ Path
110
+ </Label>
111
+ <Input
112
+ id="path"
113
+ value={path}
114
+ onChange={(e) => setPath(e.target.value)}
115
+ placeholder="/v1/users/:userId"
116
+ className="flex-1"
117
+ />
118
+ </div>
119
+
120
+ <div className="flex items-center gap-4">
121
+ <Label htmlFor="description" className="w-28 text-right shrink-0">
122
+ Description
123
+ </Label>
124
+ <Input
125
+ id="description"
126
+ value={description}
127
+ onChange={(e) => setDescription(e.target.value)}
128
+ placeholder="API description"
129
+ className="flex-1"
130
+ />
131
+ </div>
132
+
133
+ <div className="flex items-center gap-4">
134
+ <Label htmlFor="statusCode" className="w-28 text-right shrink-0">
135
+ Status Code
136
+ </Label>
137
+ <Input
138
+ id="statusCode"
139
+ type="number"
140
+ value={statusCode}
141
+ onChange={(e) => setStatusCode(Number(e.target.value))}
142
+ className="flex-1"
143
+ />
144
+ </div>
145
+
146
+ <div className="flex items-start gap-4">
147
+ <Label className="w-28 text-right shrink-0 pt-2">Response Body</Label>
148
+ <div className="flex-1">
149
+ <div className="border rounded-md overflow-hidden">
150
+ <MonacoEditor
151
+ height="calc(85vh - 320px)"
152
+ language="json"
153
+ theme="vs-dark"
154
+ value={responseBody}
155
+ onChange={(value) => setResponseBody(value || '{}')}
156
+ options={{
157
+ minimap: { enabled: false },
158
+ fontSize: 14,
159
+ formatOnPaste: true,
160
+ automaticLayout: true,
161
+ scrollBeyondLastLine: false,
162
+ }}
163
+ />
164
+ </div>
165
+ {jsonError && (
166
+ <p className="text-red-500 text-sm mt-2">{jsonError}</p>
167
+ )}
168
+ </div>
169
+ </div>
170
+ </div>
171
+
172
+ <DialogFooter className="shrink-0">
173
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
174
+ Cancel
175
+ </Button>
176
+ <Button onClick={handleSave}>Save</Button>
177
+ </DialogFooter>
178
+ </DialogContent>
179
+ </Dialog>
180
+ )
181
+ }
@@ -0,0 +1,133 @@
1
+ 'use client'
2
+
3
+ import { MockEndpoint } from '@/types/endpoint'
4
+ import {
5
+ Table,
6
+ TableBody,
7
+ TableCell,
8
+ TableHead,
9
+ TableHeader,
10
+ TableRow,
11
+ } from '@/components/ui/table'
12
+ import { Button } from '@/components/ui/button'
13
+ import { Checkbox } from '@/components/ui/checkbox'
14
+
15
+ interface EndpointTableProps {
16
+ endpoints: MockEndpoint[]
17
+ onEdit: (endpoint: MockEndpoint) => void
18
+ onDelete: (id: string) => void
19
+ selectedIds: Set<string>
20
+ onSelectionChange: (ids: Set<string>) => void
21
+ }
22
+
23
+ const methodColors: Record<string, string> = {
24
+ GET: 'bg-green-100 text-green-800',
25
+ POST: 'bg-blue-100 text-blue-800',
26
+ PUT: 'bg-yellow-100 text-yellow-800',
27
+ PATCH: 'bg-orange-100 text-orange-800',
28
+ DELETE: 'bg-red-100 text-red-800',
29
+ }
30
+
31
+ export function EndpointTable({
32
+ endpoints,
33
+ onEdit,
34
+ onDelete,
35
+ selectedIds,
36
+ onSelectionChange,
37
+ }: EndpointTableProps) {
38
+ const allSelected = endpoints.length > 0 && endpoints.every((e) => selectedIds.has(e.id))
39
+ const someSelected = endpoints.some((e) => selectedIds.has(e.id)) && !allSelected
40
+
41
+ const handleSelectAll = (checked: boolean) => {
42
+ if (checked) {
43
+ onSelectionChange(new Set(endpoints.map((e) => e.id)))
44
+ } else {
45
+ onSelectionChange(new Set())
46
+ }
47
+ }
48
+
49
+ const handleSelectOne = (id: string, checked: boolean) => {
50
+ const newSet = new Set(selectedIds)
51
+ if (checked) {
52
+ newSet.add(id)
53
+ } else {
54
+ newSet.delete(id)
55
+ }
56
+ onSelectionChange(newSet)
57
+ }
58
+
59
+ return (
60
+ <Table>
61
+ <TableHeader>
62
+ <TableRow>
63
+ <TableHead className="w-[50px]">
64
+ <Checkbox
65
+ checked={allSelected}
66
+ ref={(el) => {
67
+ if (el) {
68
+ (el as unknown as HTMLInputElement).indeterminate = someSelected
69
+ }
70
+ }}
71
+ onCheckedChange={(checked) => handleSelectAll(checked === true)}
72
+ aria-label="Select all"
73
+ />
74
+ </TableHead>
75
+ <TableHead className="w-[100px]">Method</TableHead>
76
+ <TableHead>Path</TableHead>
77
+ <TableHead>Description</TableHead>
78
+ <TableHead className="w-[150px]">Actions</TableHead>
79
+ </TableRow>
80
+ </TableHeader>
81
+ <TableBody>
82
+ {endpoints.length === 0 ? (
83
+ <TableRow>
84
+ <TableCell colSpan={5} className="text-center text-gray-500">
85
+ No endpoints registered yet
86
+ </TableCell>
87
+ </TableRow>
88
+ ) : (
89
+ endpoints.map((endpoint) => (
90
+ <TableRow key={endpoint.id}>
91
+ <TableCell>
92
+ <Checkbox
93
+ checked={selectedIds.has(endpoint.id)}
94
+ onCheckedChange={(checked) => handleSelectOne(endpoint.id, checked === true)}
95
+ aria-label={`Select ${endpoint.path}`}
96
+ />
97
+ </TableCell>
98
+ <TableCell>
99
+ <span
100
+ className={`px-2 py-1 rounded text-xs font-medium ${methodColors[endpoint.method] || 'bg-gray-100'}`}
101
+ >
102
+ {endpoint.method}
103
+ </span>
104
+ </TableCell>
105
+ <TableCell className="font-mono text-sm">{endpoint.path}</TableCell>
106
+ <TableCell className="text-gray-600">
107
+ {endpoint.description || '-'}
108
+ </TableCell>
109
+ <TableCell>
110
+ <div className="flex gap-2">
111
+ <Button
112
+ variant="outline"
113
+ size="sm"
114
+ onClick={() => onEdit(endpoint)}
115
+ >
116
+ Edit
117
+ </Button>
118
+ <Button
119
+ variant="destructive"
120
+ size="sm"
121
+ onClick={() => onDelete(endpoint.id)}
122
+ >
123
+ Delete
124
+ </Button>
125
+ </div>
126
+ </TableCell>
127
+ </TableRow>
128
+ ))
129
+ )}
130
+ </TableBody>
131
+ </Table>
132
+ )
133
+ }