@mandujs/core 0.12.1 → 0.13.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.ko.md +304 -304
- package/README.md +653 -653
- package/package.json +8 -8
- package/src/brain/architecture/analyzer.ts +28 -26
- package/src/brain/doctor/analyzer.ts +1 -1
- package/src/bundler/build.ts +91 -91
- package/src/bundler/css.ts +302 -302
- package/src/bundler/dev.ts +0 -1
- package/src/change/history.ts +3 -3
- package/src/change/snapshot.ts +10 -9
- package/src/change/transaction.ts +2 -2
- package/src/client/Link.tsx +227 -227
- package/src/client/globals.ts +44 -44
- package/src/client/hooks.ts +267 -267
- package/src/client/index.ts +5 -5
- package/src/client/island.ts +8 -8
- package/src/client/router.ts +435 -435
- package/src/client/runtime.ts +23 -23
- package/src/client/serialize.ts +404 -404
- package/src/client/window-state.ts +101 -101
- package/src/config/mandu.ts +94 -96
- package/src/config/validate.ts +213 -215
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/content-layer.ts +314 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/data-store.ts +245 -245
- package/src/content/digest.ts +133 -133
- package/src/content/index.ts +164 -164
- package/src/content/loader-context.ts +172 -172
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +169 -169
- package/src/content/loaders/glob.ts +252 -252
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/client.ts +16 -16
- package/src/contract/define.ts +459 -459
- package/src/contract/handler.ts +10 -10
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +404 -404
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/registry.ts +568 -568
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/contract/validator.ts +32 -32
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +465 -465
- package/src/devtools/client/catchers/error-catcher.ts +327 -327
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/catchers/network-proxy.ts +363 -363
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/kitchen-root.tsx +362 -362
- package/src/devtools/client/components/mandu-character.tsx +241 -241
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
- package/src/devtools/client/components/panel/index.ts +32 -32
- package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
- package/src/devtools/client/components/panel/network-panel.tsx +292 -292
- package/src/devtools/client/components/panel/panel-container.tsx +259 -259
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/client/state-manager.ts +478 -478
- package/src/devtools/design-tokens.ts +263 -263
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +266 -266
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +444 -444
- package/src/devtools/types.ts +319 -319
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +222 -222
- package/src/devtools/worker/worker-manager.ts +409 -409
- package/src/error/classifier.ts +2 -2
- package/src/error/domains.ts +265 -265
- package/src/error/formatter.ts +32 -32
- package/src/error/result.ts +46 -46
- package/src/error/stack-analyzer.ts +5 -0
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/auth.ts +308 -308
- package/src/filling/context.ts +569 -569
- package/src/filling/deps.ts +238 -238
- package/src/generator/contract-glue.ts +2 -1
- package/src/generator/generate.ts +12 -10
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.ts +80 -79
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/check.ts +128 -128
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/healing.ts +2 -0
- package/src/guard/index.ts +2 -0
- package/src/guard/negotiation.ts +430 -4
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/cqrs.test.ts +175 -0
- package/src/guard/presets/cqrs.ts +107 -0
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/presets/index.ts +291 -288
- package/src/guard/reporter.ts +445 -445
- package/src/guard/rules.ts +12 -12
- package/src/guard/statistics.ts +578 -578
- package/src/guard/suggestions.ts +358 -352
- package/src/guard/types.ts +348 -347
- package/src/guard/validator.ts +834 -834
- package/src/guard/watcher.ts +404 -404
- package/src/index.ts +1 -0
- package/src/intent/index.ts +310 -310
- package/src/island/index.ts +304 -304
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/paths.test.ts +47 -0
- package/src/paths.ts +47 -0
- package/src/plugins/index.ts +38 -38
- package/src/plugins/registry.ts +377 -377
- package/src/plugins/types.ts +363 -363
- package/src/report/build.ts +1 -1
- package/src/report/index.ts +1 -1
- package/src/router/fs-patterns.ts +387 -387
- package/src/router/fs-routes.ts +344 -401
- package/src/router/fs-scanner.ts +497 -497
- package/src/router/fs-types.ts +270 -278
- package/src/router/index.ts +81 -81
- package/src/runtime/boundary.tsx +232 -232
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/logger.ts +677 -677
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/router.ts +105 -105
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +24 -24
- package/src/runtime/session-key.ts +328 -328
- package/src/runtime/ssr.ts +367 -367
- package/src/runtime/streaming-ssr.ts +1245 -1245
- package/src/runtime/trace.ts +144 -144
- package/src/seo/index.ts +214 -214
- package/src/seo/integration/ssr.ts +307 -307
- package/src/seo/render/basic.ts +427 -427
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +191 -191
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +126 -126
- package/src/seo/resolve/index.ts +353 -353
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +97 -97
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +575 -575
- package/src/slot/validator.ts +39 -39
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
- package/src/utils/bun.ts +8 -8
- package/src/utils/lru-cache.ts +75 -75
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/rules.ts +5 -5
package/src/index.ts
CHANGED
package/src/intent/index.ts
CHANGED
|
@@ -1,310 +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
|
-
}
|
|
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
|
+
}
|