@mandujs/core 0.9.41 → 0.9.42
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/package.json +1 -1
- package/src/bundler/build.ts +91 -73
- package/src/bundler/dev.ts +21 -14
- package/src/client/globals.ts +44 -0
- package/src/client/index.ts +5 -4
- package/src/client/island.ts +8 -13
- package/src/client/router.ts +33 -41
- package/src/client/runtime.ts +23 -51
- package/src/client/window-state.ts +101 -0
- package/src/config/index.ts +1 -0
- package/src/config/mandu.ts +45 -9
- package/src/config/validate.ts +158 -0
- package/src/constants.ts +25 -0
- package/src/contract/client.ts +4 -3
- package/src/contract/define.ts +459 -0
- package/src/devtools/ai/context-builder.ts +375 -0
- package/src/devtools/ai/index.ts +25 -0
- package/src/devtools/ai/mcp-connector.ts +465 -0
- package/src/devtools/client/catchers/error-catcher.ts +327 -0
- package/src/devtools/client/catchers/index.ts +18 -0
- package/src/devtools/client/catchers/network-proxy.ts +363 -0
- package/src/devtools/client/components/index.ts +39 -0
- package/src/devtools/client/components/kitchen-root.tsx +362 -0
- package/src/devtools/client/components/mandu-character.tsx +241 -0
- package/src/devtools/client/components/overlay.tsx +368 -0
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -0
- package/src/devtools/client/components/panel/guard-panel.tsx +244 -0
- package/src/devtools/client/components/panel/index.ts +32 -0
- package/src/devtools/client/components/panel/islands-panel.tsx +304 -0
- package/src/devtools/client/components/panel/network-panel.tsx +292 -0
- package/src/devtools/client/components/panel/panel-container.tsx +259 -0
- package/src/devtools/client/filters/context-filters.ts +282 -0
- package/src/devtools/client/filters/index.ts +16 -0
- package/src/devtools/client/index.ts +63 -0
- package/src/devtools/client/persistence.ts +335 -0
- package/src/devtools/client/state-manager.ts +478 -0
- package/src/devtools/design-tokens.ts +263 -0
- package/src/devtools/hook/create-hook.ts +207 -0
- package/src/devtools/hook/index.ts +13 -0
- package/src/devtools/index.ts +439 -0
- package/src/devtools/init.ts +266 -0
- package/src/devtools/protocol.ts +237 -0
- package/src/devtools/server/index.ts +17 -0
- package/src/devtools/server/source-context.ts +444 -0
- package/src/devtools/types.ts +319 -0
- package/src/devtools/worker/index.ts +25 -0
- package/src/devtools/worker/redaction-worker.ts +222 -0
- package/src/devtools/worker/worker-manager.ts +409 -0
- package/src/error/formatter.ts +28 -24
- package/src/error/index.ts +13 -9
- package/src/error/result.ts +46 -0
- package/src/error/types.ts +6 -4
- package/src/filling/filling.ts +6 -5
- package/src/guard/check.ts +60 -56
- package/src/guard/types.ts +3 -1
- package/src/guard/watcher.ts +10 -1
- package/src/index.ts +81 -0
- package/src/intent/index.ts +310 -0
- package/src/island/index.ts +304 -0
- package/src/router/fs-patterns.ts +7 -0
- package/src/router/fs-routes.ts +20 -8
- package/src/router/fs-scanner.ts +117 -133
- package/src/runtime/server.ts +261 -201
- package/src/runtime/ssr.ts +5 -4
- package/src/runtime/streaming-ssr.ts +5 -4
- package/src/utils/bun.ts +8 -0
- package/src/utils/lru-cache.ts +75 -0
package/src/guard/check.ts
CHANGED
|
@@ -368,63 +368,67 @@ export async function runGuardCheck(
|
|
|
368
368
|
manifest: RoutesManifest,
|
|
369
369
|
rootDir: string
|
|
370
370
|
): Promise<GuardCheckResult> {
|
|
371
|
-
const violations: GuardViolation[] = [];
|
|
372
371
|
const config = await loadManduConfig(rootDir);
|
|
373
|
-
|
|
374
|
-
const lockPath = path.join(rootDir, "spec/spec.lock.json");
|
|
375
|
-
const mapPath = path.join(rootDir, "packages/core/map/generated.map.json");
|
|
376
|
-
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
//
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
372
|
+
|
|
373
|
+
const lockPath = path.join(rootDir, "spec/spec.lock.json");
|
|
374
|
+
const mapPath = path.join(rootDir, "packages/core/map/generated.map.json");
|
|
375
|
+
|
|
376
|
+
// ============================================
|
|
377
|
+
// Phase 1: 독립적인 검사 병렬 실행
|
|
378
|
+
// ============================================
|
|
379
|
+
const [
|
|
380
|
+
hashViolation,
|
|
381
|
+
importViolations,
|
|
382
|
+
slotViolations,
|
|
383
|
+
specDirViolations,
|
|
384
|
+
islandViolations,
|
|
385
|
+
] = await Promise.all([
|
|
386
|
+
checkSpecHashMismatch(manifest, lockPath),
|
|
387
|
+
checkInvalidGeneratedImport(rootDir),
|
|
388
|
+
checkSlotFileExists(manifest, rootDir),
|
|
389
|
+
checkSpecDirNaming(rootDir),
|
|
390
|
+
checkIslandFirstIntegrity(manifest, rootDir),
|
|
391
|
+
]);
|
|
392
|
+
|
|
393
|
+
const violations: GuardViolation[] = [];
|
|
394
|
+
if (hashViolation) violations.push(hashViolation);
|
|
395
|
+
violations.push(...importViolations);
|
|
396
|
+
violations.push(...slotViolations);
|
|
397
|
+
violations.push(...specDirViolations);
|
|
398
|
+
violations.push(...islandViolations);
|
|
399
|
+
|
|
400
|
+
// ============================================
|
|
401
|
+
// Phase 2: generatedMap 의존 검사
|
|
402
|
+
// ============================================
|
|
403
|
+
let generatedMap: GeneratedMap | null = null;
|
|
404
|
+
if (await fileExists(mapPath)) {
|
|
405
|
+
try {
|
|
406
|
+
const mapContent = await Bun.file(mapPath).text();
|
|
407
|
+
generatedMap = JSON.parse(mapContent);
|
|
408
|
+
} catch {
|
|
409
|
+
// Map file corrupted or missing
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (generatedMap) {
|
|
414
|
+
const [editViolations, forbiddenViolations] = await Promise.all([
|
|
415
|
+
checkGeneratedManualEdit(rootDir, generatedMap),
|
|
416
|
+
checkForbiddenImportsInGenerated(rootDir, generatedMap),
|
|
417
|
+
]);
|
|
418
|
+
violations.push(...editViolations);
|
|
419
|
+
violations.push(...forbiddenViolations);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ============================================
|
|
423
|
+
// Phase 3: Slot + Contract 검사 병렬
|
|
424
|
+
// ============================================
|
|
425
|
+
const [slotContentViolations, contractViolations] = await Promise.all([
|
|
426
|
+
checkSlotContentValidation(manifest, rootDir),
|
|
427
|
+
runContractGuardCheck(manifest, rootDir),
|
|
428
|
+
]);
|
|
429
|
+
violations.push(...slotContentViolations);
|
|
430
|
+
violations.push(...contractViolations);
|
|
431
|
+
|
|
428
432
|
const resolvedViolations = applyRuleSeverity(violations, config.guard ?? {});
|
|
429
433
|
const passed = resolvedViolations.every((v) => v.severity !== "error");
|
|
430
434
|
|
package/src/guard/types.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* @module guard/types
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { TIMEOUTS } from "../constants";
|
|
10
|
+
|
|
9
11
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
10
12
|
// Configuration Types
|
|
11
13
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -336,7 +338,7 @@ export const DEFAULT_GUARD_CONFIG: Required<Omit<GuardConfig, "preset" | "layers
|
|
|
336
338
|
realtimeOutput: "console",
|
|
337
339
|
cache: true,
|
|
338
340
|
incremental: true,
|
|
339
|
-
debounceMs:
|
|
341
|
+
debounceMs: TIMEOUTS.WATCHER_DEBOUNCE,
|
|
340
342
|
};
|
|
341
343
|
|
|
342
344
|
/**
|
package/src/guard/watcher.ts
CHANGED
|
@@ -45,6 +45,15 @@ interface WatcherOptions {
|
|
|
45
45
|
|
|
46
46
|
const analysisCache = new Map<string, FileAnalysis>();
|
|
47
47
|
|
|
48
|
+
let globModulePromise: Promise<typeof import("glob")> | null = null;
|
|
49
|
+
|
|
50
|
+
async function getGlobModule(): Promise<typeof import("glob")> {
|
|
51
|
+
if (!globModulePromise) {
|
|
52
|
+
globModulePromise = import("glob");
|
|
53
|
+
}
|
|
54
|
+
return globModulePromise;
|
|
55
|
+
}
|
|
56
|
+
|
|
48
57
|
/**
|
|
49
58
|
* 캐시 초기화
|
|
50
59
|
*/
|
|
@@ -167,7 +176,7 @@ export function createGuardWatcher(options: WatcherOptions): GuardWatcher {
|
|
|
167
176
|
const analyses: FileAnalysis[] = [];
|
|
168
177
|
|
|
169
178
|
// 글로브로 모든 파일 찾기
|
|
170
|
-
const { glob } = await
|
|
179
|
+
const { glob } = await getGlobModule();
|
|
171
180
|
const extensions = WATCH_EXTENSIONS.map((ext) => ext.slice(1)).join(",");
|
|
172
181
|
const scanRoots = new Set<string>([srcDir]);
|
|
173
182
|
if (config.fsRoutes) {
|
package/src/index.ts
CHANGED
|
@@ -15,10 +15,17 @@ export * from "./watcher";
|
|
|
15
15
|
export * from "./router";
|
|
16
16
|
export * from "./config";
|
|
17
17
|
export * from "./seo";
|
|
18
|
+
export * from "./island";
|
|
19
|
+
export * from "./intent";
|
|
20
|
+
export * from "./devtools";
|
|
18
21
|
|
|
19
22
|
// Consolidated Mandu namespace
|
|
20
23
|
import { ManduFilling, ManduContext, ManduFillingFactory } from "./filling";
|
|
21
24
|
import { createContract, defineHandler, defineRoute, createClient, contractFetch, createClientContract } from "./contract";
|
|
25
|
+
import { defineContract, generateAllFromContract, generateOpenAPISpec } from "./contract/define";
|
|
26
|
+
import { island, isIsland, type IslandComponent, type HydrationStrategy } from "./island";
|
|
27
|
+
import { intent, isIntent, getIntentDocs, generateOpenAPIFromIntent } from "./intent";
|
|
28
|
+
import { initializeHook, reportError, ManduDevTools, getStateManager } from "./devtools";
|
|
22
29
|
import type { ContractDefinition, ContractInstance, ContractSchema } from "./contract";
|
|
23
30
|
import type { ContractHandlers, ClientOptions } from "./contract";
|
|
24
31
|
|
|
@@ -94,4 +101,78 @@ export const Mandu = {
|
|
|
94
101
|
* Make a type-safe fetch call
|
|
95
102
|
*/
|
|
96
103
|
fetch: contractFetch,
|
|
104
|
+
|
|
105
|
+
// === AI-Native APIs ===
|
|
106
|
+
/**
|
|
107
|
+
* Define a Contract for code generation
|
|
108
|
+
* @example
|
|
109
|
+
* const api = Mandu.define({
|
|
110
|
+
* getUser: { method: 'GET', path: '/users/:id', output: userSchema },
|
|
111
|
+
* });
|
|
112
|
+
*/
|
|
113
|
+
define: defineContract,
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create an Island component (declarative hydration)
|
|
117
|
+
* @example
|
|
118
|
+
* export default Mandu.island('visible', ({ name }) => <div>{name}</div>);
|
|
119
|
+
*/
|
|
120
|
+
island,
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Check if a component is an Island
|
|
124
|
+
*/
|
|
125
|
+
isIsland,
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Create an Intent-based API handler
|
|
129
|
+
* @example
|
|
130
|
+
* export default Mandu.intent({
|
|
131
|
+
* '사용자 조회': { method: 'GET', handler: (ctx) => ctx.ok(user) },
|
|
132
|
+
* });
|
|
133
|
+
*/
|
|
134
|
+
intent,
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Check if a handler is an Intent
|
|
138
|
+
*/
|
|
139
|
+
isIntent,
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Generate code from Contract
|
|
143
|
+
*/
|
|
144
|
+
generate: generateAllFromContract,
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Generate OpenAPI spec from Contract
|
|
148
|
+
*/
|
|
149
|
+
openapi: generateOpenAPISpec,
|
|
150
|
+
|
|
151
|
+
// === DevTools API ===
|
|
152
|
+
/**
|
|
153
|
+
* Initialize DevTools hook (call at app startup)
|
|
154
|
+
* @example
|
|
155
|
+
* Mandu.devtools.init();
|
|
156
|
+
*/
|
|
157
|
+
devtools: {
|
|
158
|
+
/**
|
|
159
|
+
* Initialize DevTools
|
|
160
|
+
*/
|
|
161
|
+
init: initializeHook,
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Report an error to DevTools
|
|
165
|
+
*/
|
|
166
|
+
reportError,
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get the state manager instance
|
|
170
|
+
*/
|
|
171
|
+
getStateManager,
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* DevTools public API (also available as window.ManduDevTools)
|
|
175
|
+
*/
|
|
176
|
+
api: ManduDevTools,
|
|
177
|
+
},
|
|
97
178
|
} as const;
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mandu Intent - 의도 기반 API 라우팅
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { intent } from '@mandujs/core';
|
|
7
|
+
*
|
|
8
|
+
* export default intent({
|
|
9
|
+
* '사용자 목록 조회': {
|
|
10
|
+
* method: 'GET',
|
|
11
|
+
* handler: (ctx) => ctx.ok(users),
|
|
12
|
+
* },
|
|
13
|
+
* '사용자 생성': {
|
|
14
|
+
* method: 'POST',
|
|
15
|
+
* input: z.object({ name: z.string() }),
|
|
16
|
+
* handler: async (ctx) => {
|
|
17
|
+
* const data = await ctx.body();
|
|
18
|
+
* return ctx.created(createUser(data));
|
|
19
|
+
* },
|
|
20
|
+
* },
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { z, type ZodType } from 'zod';
|
|
26
|
+
import { ManduFillingFactory, type ManduFilling } from '../filling/filling';
|
|
27
|
+
import type { ManduContext } from '../filling/context';
|
|
28
|
+
|
|
29
|
+
// ============================================================================
|
|
30
|
+
// Types
|
|
31
|
+
// ============================================================================
|
|
32
|
+
|
|
33
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS';
|
|
34
|
+
|
|
35
|
+
export interface IntentDefinition<TInput = unknown, TOutput = unknown> {
|
|
36
|
+
/** HTTP 메서드 */
|
|
37
|
+
method: HttpMethod;
|
|
38
|
+
/** 추가 경로 (예: '/:id') */
|
|
39
|
+
path?: string;
|
|
40
|
+
/** 입력 스키마 (Zod) */
|
|
41
|
+
input?: ZodType<TInput>;
|
|
42
|
+
/** 출력 스키마 (Zod) - 문서화/검증용 */
|
|
43
|
+
output?: ZodType<TOutput>;
|
|
44
|
+
/** 가능한 에러 코드 목록 */
|
|
45
|
+
errors?: readonly string[];
|
|
46
|
+
/** 설명 (OpenAPI 문서용) */
|
|
47
|
+
description?: string;
|
|
48
|
+
/** 핸들러 함수 */
|
|
49
|
+
handler: (ctx: ManduContext) => Response | Promise<Response>;
|
|
50
|
+
/** Guard/Middleware */
|
|
51
|
+
guard?: (ctx: ManduContext) => Response | void | Promise<Response | void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type IntentMap = Record<string, IntentDefinition<any, any>>;
|
|
55
|
+
|
|
56
|
+
export interface IntentMeta {
|
|
57
|
+
__intent: true;
|
|
58
|
+
__intents: IntentMap;
|
|
59
|
+
__docs: IntentDocumentation[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface IntentDocumentation {
|
|
63
|
+
name: string;
|
|
64
|
+
method: HttpMethod;
|
|
65
|
+
path: string;
|
|
66
|
+
description?: string;
|
|
67
|
+
input?: ZodType<unknown>;
|
|
68
|
+
output?: ZodType<unknown>;
|
|
69
|
+
errors?: readonly string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// intent() - 의도 기반 API 생성
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 의도 기반 API 라우트 생성
|
|
78
|
+
*
|
|
79
|
+
* 하나의 파일에서 여러 관련 API를 의도(intent)로 그룹화
|
|
80
|
+
* - 의도 이름이 자동으로 OpenAPI description이 됨
|
|
81
|
+
* - AI가 "사용자 삭제 API"를 쉽게 찾을 수 있음
|
|
82
|
+
* - 타입 안전한 입출력
|
|
83
|
+
*/
|
|
84
|
+
export function intent(intents: IntentMap): ManduFilling & IntentMeta {
|
|
85
|
+
const filling = ManduFillingFactory.filling();
|
|
86
|
+
const docs: IntentDocumentation[] = [];
|
|
87
|
+
|
|
88
|
+
// 메서드별로 핸들러 그룹화
|
|
89
|
+
const methodHandlers: Record<HttpMethod, IntentDefinition<any, any>[]> = {
|
|
90
|
+
GET: [],
|
|
91
|
+
POST: [],
|
|
92
|
+
PUT: [],
|
|
93
|
+
PATCH: [],
|
|
94
|
+
DELETE: [],
|
|
95
|
+
HEAD: [],
|
|
96
|
+
OPTIONS: [],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Intent 분류 및 문서화
|
|
100
|
+
for (const [intentName, definition] of Object.entries(intents)) {
|
|
101
|
+
methodHandlers[definition.method].push({
|
|
102
|
+
...definition,
|
|
103
|
+
description: definition.description || intentName,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
docs.push({
|
|
107
|
+
name: intentName,
|
|
108
|
+
method: definition.method,
|
|
109
|
+
path: definition.path || '/',
|
|
110
|
+
description: definition.description || intentName,
|
|
111
|
+
input: definition.input,
|
|
112
|
+
output: definition.output,
|
|
113
|
+
errors: definition.errors,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 각 메서드에 대해 핸들러 등록
|
|
118
|
+
const registerMethod = (method: HttpMethod, handlers: IntentDefinition<any, any>[]) => {
|
|
119
|
+
if (handlers.length === 0) return;
|
|
120
|
+
|
|
121
|
+
const methodLower = method.toLowerCase() as Lowercase<HttpMethod>;
|
|
122
|
+
|
|
123
|
+
(filling as any)[methodLower](async (ctx: ManduContext) => {
|
|
124
|
+
// 경로 매칭 (path가 있는 경우)
|
|
125
|
+
for (const def of handlers) {
|
|
126
|
+
if (def.path && !matchPath(ctx.url, def.path)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Guard 실행
|
|
131
|
+
if (def.guard) {
|
|
132
|
+
const guardResult = await def.guard(ctx);
|
|
133
|
+
if (guardResult instanceof Response) {
|
|
134
|
+
return guardResult;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Input 검증
|
|
139
|
+
if (def.input && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
|
140
|
+
const bodyResult = await ctx.body(def.input);
|
|
141
|
+
if (!bodyResult.success) {
|
|
142
|
+
return ctx.error('Validation failed', bodyResult.error);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 핸들러 실행
|
|
147
|
+
return def.handler(ctx);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 매칭되는 핸들러 없음
|
|
151
|
+
return ctx.notFound(`No handler for ${method} ${ctx.url}`);
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// 모든 메서드 등록
|
|
156
|
+
for (const [method, handlers] of Object.entries(methodHandlers)) {
|
|
157
|
+
registerMethod(method as HttpMethod, handlers);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 메타데이터 부착
|
|
161
|
+
const result = filling as ManduFilling & IntentMeta;
|
|
162
|
+
result.__intent = true;
|
|
163
|
+
result.__intents = intents;
|
|
164
|
+
result.__docs = docs;
|
|
165
|
+
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ============================================================================
|
|
170
|
+
// Helper Functions
|
|
171
|
+
// ============================================================================
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 간단한 경로 매칭 (/:id 같은 패턴)
|
|
175
|
+
*/
|
|
176
|
+
function matchPath(url: string, pattern: string): boolean {
|
|
177
|
+
if (pattern === '/') return true;
|
|
178
|
+
|
|
179
|
+
const urlPath = new URL(url, 'http://localhost').pathname;
|
|
180
|
+
const patternParts = pattern.split('/').filter(Boolean);
|
|
181
|
+
const urlParts = urlPath.split('/').filter(Boolean);
|
|
182
|
+
|
|
183
|
+
if (patternParts.length !== urlParts.length) return false;
|
|
184
|
+
|
|
185
|
+
return patternParts.every((part, i) => {
|
|
186
|
+
if (part.startsWith(':')) return true; // 동적 파라미터
|
|
187
|
+
return part === urlParts[i];
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Intent에서 OpenAPI 스펙 생성
|
|
193
|
+
*/
|
|
194
|
+
export function generateOpenAPIFromIntent(
|
|
195
|
+
basePath: string,
|
|
196
|
+
intentFilling: IntentMeta
|
|
197
|
+
): Record<string, unknown> {
|
|
198
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
199
|
+
|
|
200
|
+
for (const doc of intentFilling.__docs) {
|
|
201
|
+
const fullPath = basePath + (doc.path === '/' ? '' : doc.path);
|
|
202
|
+
const method = doc.method.toLowerCase();
|
|
203
|
+
|
|
204
|
+
if (!paths[fullPath]) {
|
|
205
|
+
paths[fullPath] = {};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
paths[fullPath][method] = {
|
|
209
|
+
summary: doc.name,
|
|
210
|
+
description: doc.description,
|
|
211
|
+
requestBody: doc.input
|
|
212
|
+
? {
|
|
213
|
+
content: {
|
|
214
|
+
'application/json': {
|
|
215
|
+
schema: zodToJsonSchema(doc.input),
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
}
|
|
219
|
+
: undefined,
|
|
220
|
+
responses: {
|
|
221
|
+
'200': {
|
|
222
|
+
description: 'Success',
|
|
223
|
+
content: doc.output
|
|
224
|
+
? {
|
|
225
|
+
'application/json': {
|
|
226
|
+
schema: zodToJsonSchema(doc.output),
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
: undefined,
|
|
230
|
+
},
|
|
231
|
+
...(doc.errors?.reduce(
|
|
232
|
+
(acc, error) => ({
|
|
233
|
+
...acc,
|
|
234
|
+
[getErrorStatusCode(error)]: { description: error },
|
|
235
|
+
}),
|
|
236
|
+
{}
|
|
237
|
+
) || {}),
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { paths };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 간단한 Zod → JSON Schema 변환
|
|
247
|
+
*/
|
|
248
|
+
function zodToJsonSchema(schema: ZodType<unknown>): Record<string, unknown> {
|
|
249
|
+
// 실제 구현은 zod-to-json-schema 라이브러리 사용 권장
|
|
250
|
+
const def = (schema as any)._def;
|
|
251
|
+
|
|
252
|
+
if (def.typeName === 'ZodString') {
|
|
253
|
+
return { type: 'string' };
|
|
254
|
+
}
|
|
255
|
+
if (def.typeName === 'ZodNumber') {
|
|
256
|
+
return { type: 'number' };
|
|
257
|
+
}
|
|
258
|
+
if (def.typeName === 'ZodBoolean') {
|
|
259
|
+
return { type: 'boolean' };
|
|
260
|
+
}
|
|
261
|
+
if (def.typeName === 'ZodObject') {
|
|
262
|
+
const properties: Record<string, unknown> = {};
|
|
263
|
+
for (const [key, value] of Object.entries(def.shape())) {
|
|
264
|
+
properties[key] = zodToJsonSchema(value as ZodType<unknown>);
|
|
265
|
+
}
|
|
266
|
+
return { type: 'object', properties };
|
|
267
|
+
}
|
|
268
|
+
if (def.typeName === 'ZodArray') {
|
|
269
|
+
return { type: 'array', items: zodToJsonSchema(def.type) };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { type: 'unknown' };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 에러 코드 → HTTP 상태 코드
|
|
277
|
+
*/
|
|
278
|
+
function getErrorStatusCode(error: string): number {
|
|
279
|
+
const errorMap: Record<string, number> = {
|
|
280
|
+
NOT_FOUND: 404,
|
|
281
|
+
UNAUTHORIZED: 401,
|
|
282
|
+
FORBIDDEN: 403,
|
|
283
|
+
RATE_LIMITED: 429,
|
|
284
|
+
INVALID_INPUT: 400,
|
|
285
|
+
VALIDATION_ERROR: 400,
|
|
286
|
+
INTERNAL_ERROR: 500,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
return errorMap[error] || 400;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ============================================================================
|
|
293
|
+
// isIntent() - Intent 체크
|
|
294
|
+
// ============================================================================
|
|
295
|
+
|
|
296
|
+
export function isIntent(value: unknown): value is ManduFilling & IntentMeta {
|
|
297
|
+
return (
|
|
298
|
+
typeof value === 'object' &&
|
|
299
|
+
value !== null &&
|
|
300
|
+
(value as IntentMeta).__intent === true
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ============================================================================
|
|
305
|
+
// getIntentDocs() - Intent 문서 추출
|
|
306
|
+
// ============================================================================
|
|
307
|
+
|
|
308
|
+
export function getIntentDocs(intentFilling: IntentMeta): IntentDocumentation[] {
|
|
309
|
+
return intentFilling.__docs;
|
|
310
|
+
}
|