@fuzionx/framework 0.1.2

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 (45) hide show
  1. package/bin/fx.js +12 -0
  2. package/index.js +64 -0
  3. package/lib/core/AppError.js +46 -0
  4. package/lib/core/Application.js +553 -0
  5. package/lib/core/AutoLoader.js +162 -0
  6. package/lib/core/Base.js +64 -0
  7. package/lib/core/Config.js +122 -0
  8. package/lib/core/Context.js +429 -0
  9. package/lib/database/ConnectionManager.js +192 -0
  10. package/lib/database/MariaModel.js +29 -0
  11. package/lib/database/Model.js +247 -0
  12. package/lib/database/ModelRegistry.js +72 -0
  13. package/lib/database/MongoModel.js +232 -0
  14. package/lib/database/Pagination.js +37 -0
  15. package/lib/database/PostgreModel.js +29 -0
  16. package/lib/database/QueryBuilder.js +172 -0
  17. package/lib/database/SQLiteModel.js +27 -0
  18. package/lib/database/SqlModel.js +252 -0
  19. package/lib/database/SqlQueryBuilder.js +309 -0
  20. package/lib/helpers/CryptoHelper.js +48 -0
  21. package/lib/helpers/FileHelper.js +61 -0
  22. package/lib/helpers/HashHelper.js +39 -0
  23. package/lib/helpers/I18nHelper.js +170 -0
  24. package/lib/helpers/Logger.js +105 -0
  25. package/lib/helpers/MediaHelper.js +38 -0
  26. package/lib/http/Controller.js +34 -0
  27. package/lib/http/ErrorHandler.js +135 -0
  28. package/lib/http/Middleware.js +43 -0
  29. package/lib/http/Router.js +109 -0
  30. package/lib/http/Validation.js +124 -0
  31. package/lib/middleware/index.js +286 -0
  32. package/lib/realtime/RoomManager.js +85 -0
  33. package/lib/realtime/WsHandler.js +107 -0
  34. package/lib/schedule/Job.js +34 -0
  35. package/lib/schedule/Queue.js +90 -0
  36. package/lib/schedule/Scheduler.js +161 -0
  37. package/lib/schedule/Task.js +39 -0
  38. package/lib/schedule/WorkerPool.js +225 -0
  39. package/lib/services/EventBus.js +94 -0
  40. package/lib/services/Service.js +261 -0
  41. package/lib/services/Storage.js +112 -0
  42. package/lib/view/OpenAPI.js +231 -0
  43. package/lib/view/View.js +72 -0
  44. package/package.json +52 -0
  45. package/testing/index.js +232 -0
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Logger — Bridge tracing 통합 로거
3
+ *
4
+ * Bridge(Rust) logInfo/logWarn/logError/logDebug N-API에 위임.
5
+ * Bridge 없으면 JS console 폴백.
6
+ *
7
+ * @see docs/framework/13-logging.md
8
+ * @see packages/fuzionx/lib/logger.js (Core 래퍼 참조)
9
+ */
10
+ import { format } from 'node:util';
11
+
12
+ export default class Logger {
13
+ /**
14
+ * @param {object} [opts]
15
+ * @param {string} [opts.level='info'] - 최소 출력 레벨
16
+ * @param {object} [opts.bridge] - Bridge N-API 인스턴스
17
+ * @param {boolean} [opts.json=false] - JSON 포맷 (Bridge 없을 때)
18
+ * @param {string} [opts.prefix='app'] - Bridge target 이름
19
+ */
20
+ constructor(opts = {}) {
21
+ this.level = opts.level || 'info';
22
+ this._bridge = opts.bridge || null;
23
+ this._json = opts.json || false;
24
+ this._prefix = opts.prefix || 'app';
25
+ this._levels = { error: 0, warn: 1, info: 2, debug: 3 };
26
+ this._minLevel = this._levels[this.level] ?? 2;
27
+ }
28
+
29
+ /**
30
+ * @param {string} message
31
+ * @param {object} [context]
32
+ */
33
+ error(message, context) { this._log('error', message, context); }
34
+ warn(message, context) { this._log('warn', message, context); }
35
+ info(message, context) { this._log('info', message, context); }
36
+ debug(message, context) { this._log('debug', message, context); }
37
+
38
+ /**
39
+ * 자식 로거 생성 (prefix 상속)
40
+ * @param {string} prefix
41
+ * @returns {Logger}
42
+ */
43
+ child(prefix) {
44
+ return new Logger({
45
+ level: this.level,
46
+ bridge: this._bridge,
47
+ json: this._json,
48
+ prefix: this._prefix ? `${this._prefix}:${prefix}` : prefix,
49
+ });
50
+ }
51
+
52
+ /** @private */
53
+ _log(level, message, context) {
54
+ if (this._levels[level] > this._minLevel) return;
55
+
56
+ const msg = context
57
+ ? `${message} ${JSON.stringify(context)}`
58
+ : message;
59
+
60
+ // ── Bridge N-API 위임 ──
61
+ // Core logger.js 참조: bridge.logInfo(target, msg), bridge.logWarn(target, msg, location?)
62
+ if (this._bridge) {
63
+ try {
64
+ if (level === 'info' && typeof this._bridge.logInfo === 'function') {
65
+ this._bridge.logInfo(this._prefix, msg);
66
+ return;
67
+ }
68
+ if (level === 'warn' && typeof this._bridge.logWarn === 'function') {
69
+ this._bridge.logWarn(this._prefix, msg);
70
+ return;
71
+ }
72
+ if (level === 'error' && typeof this._bridge.logError === 'function') {
73
+ this._bridge.logError(this._prefix, msg);
74
+ return;
75
+ }
76
+ if (level === 'debug' && typeof this._bridge.logDebug === 'function') {
77
+ this._bridge.logDebug(this._prefix, msg);
78
+ return;
79
+ }
80
+ } catch {} // Bridge 호출 실패 시 JS 폴백
81
+ }
82
+
83
+ // ── JS 폴백 ──
84
+ const timestamp = new Date().toISOString();
85
+
86
+ if (this._json) {
87
+ const entry = {
88
+ timestamp,
89
+ level,
90
+ target: this._prefix,
91
+ message,
92
+ ...(context || {}),
93
+ };
94
+ console[level === 'debug' ? 'log' : level](JSON.stringify(entry));
95
+ } else {
96
+ const levelTag = level.toUpperCase().padEnd(5);
97
+ const fullMsg = `${timestamp} ${levelTag} [${this._prefix}] ${message}`;
98
+ if (context && Object.keys(context).length > 0) {
99
+ console[level === 'debug' ? 'log' : level](fullMsg, context);
100
+ } else {
101
+ console[level === 'debug' ? 'log' : level](fullMsg);
102
+ }
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * MediaHelper — Bridge media N-API 래퍼
3
+ *
4
+ * @see docs/framework/19-utilities.md — "3. Media (app.media)"
5
+ * @see packages/fuzionx/lib/media.js (Core 래퍼)
6
+ */
7
+ export default class MediaHelper {
8
+ constructor(bridge) { this._bridge = bridge; }
9
+
10
+ resize(input, output, width, height, format = 'webp', quality = 80) {
11
+ if (this._bridge?.mediaResize) return this._bridge.mediaResize(input, output, width, height, format, quality);
12
+ throw new Error('Media operations require Bridge (Rust). Install @fuzionx/bridge.');
13
+ }
14
+
15
+ resizeMultiple(input, outputDir, baseName, specs) {
16
+ if (this._bridge?.mediaResizeMultiple) return this._bridge.mediaResizeMultiple(input, outputDir, baseName, JSON.stringify(specs));
17
+ throw new Error('Media operations require Bridge (Rust).');
18
+ }
19
+
20
+ imageInfo(filePath) {
21
+ if (this._bridge?.mediaGetImageInfo) return this._bridge.mediaGetImageInfo(filePath);
22
+ throw new Error('Image info requires Bridge (Rust).');
23
+ }
24
+
25
+ toWebp(input, output, quality = 80) {
26
+ return this.resize(input, output, 0, 0, 'webp', quality);
27
+ }
28
+
29
+ videoThumbnail(input, output, atSeconds = 1, width = 640, format = 'jpeg') {
30
+ if (this._bridge?.mediaVideoThumbnail) return this._bridge.mediaVideoThumbnail(input, output, atSeconds, width, format);
31
+ throw new Error('Video operations require ffmpeg + Bridge.');
32
+ }
33
+
34
+ videoInfo(filePath) {
35
+ if (this._bridge?.mediaVideoInfo) return this._bridge.mediaVideoInfo(filePath);
36
+ throw new Error('Video info requires ffprobe + Bridge.');
37
+ }
38
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Controller — HTTP 요청 핸들러 기본 클래스
3
+ *
4
+ * 싱글톤: 부트 시 1회 생성. 핸들러 메서드는 ctx만으로 동작.
5
+ *
6
+ * @see docs/framework/01-routing-controllers.md
7
+ * @see docs/framework/class-design.mm.md (Controller)
8
+ */
9
+ import Base from '../core/Base.js';
10
+
11
+ export default class Controller extends Base {
12
+ /** @type {string[]} 이 컨트롤러 전체에 적용할 미들웨어 */
13
+ static middleware = [];
14
+
15
+ /**
16
+ * 프레임워크 내부 — 컨트롤러 스캔 시 프로토타입 메서드를 static 레퍼런스로 자동 등록
17
+ * @param {typeof Controller} ControllerClass
18
+ */
19
+ static register(ControllerClass) {
20
+ const proto = ControllerClass.prototype;
21
+ const baseNames = new Set(Object.getOwnPropertyNames(Base.prototype));
22
+ for (const method of Object.getOwnPropertyNames(proto)) {
23
+ if (method === 'constructor') continue;
24
+ // Base 메서드/getter 전체 스킵 (service, emit, dispatch, ws, crypto, i18n)
25
+ if (baseNames.has(method)) continue;
26
+
27
+ ControllerClass[method] = {
28
+ __handler__: true,
29
+ controller: ControllerClass,
30
+ method,
31
+ };
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * ErrorHandler — 기본 에러 핸들러 (JSON/HTML 분기 + 테마 에러 페이지)
3
+ *
4
+ * @see docs/framework/08-error-handling.md
5
+ * @see docs/framework/03-views-templates.md (에러 페이지)
6
+ */
7
+ import AppError, { ValidationError } from '../core/AppError.js';
8
+
9
+ export default class ErrorHandler {
10
+ /**
11
+ * @param {object} opts
12
+ * @param {boolean} [opts.isDev=false] - dev 모드에서 스택 노출
13
+ * @param {Function} [opts.logger] - 에러 로깅 함수
14
+ * @param {import('./View.js').default} [opts.view] - 뷰 렌더러 (테마 에러 페이지용)
15
+ */
16
+ constructor(opts = {}) {
17
+ this.isDev = opts.isDev ?? false;
18
+ this.logger = opts.logger || console.error;
19
+ this._view = opts.view || null;
20
+ }
21
+
22
+ /**
23
+ * 에러 처리 — ctx에 응답 세팅
24
+ * @param {Error} err
25
+ * @param {import('./Context.js').default} ctx
26
+ */
27
+ handle(err, ctx) {
28
+ const status = err.status || 500;
29
+
30
+ // 500 에러는 로깅
31
+ if (status >= 500) {
32
+ this.logger(err);
33
+ }
34
+
35
+ // JSON 응답 요청이면 JSON으로
36
+ const wantsJson = ctx.get('accept')?.includes('application/json')
37
+ || ctx.is('json')
38
+ || ctx.path?.startsWith('/api');
39
+
40
+ if (wantsJson) {
41
+ return this._jsonResponse(err, ctx, status);
42
+ }
43
+
44
+ return this._htmlResponse(err, ctx, status);
45
+ }
46
+
47
+ /** @private */
48
+ _jsonResponse(err, ctx, status) {
49
+ const body = {
50
+ error: {
51
+ message: err.message,
52
+ status,
53
+ },
54
+ };
55
+
56
+ // ValidationError → 필드 에러 포함
57
+ if (err instanceof ValidationError) {
58
+ body.error.fields = err.fields;
59
+ }
60
+
61
+ // dev 모드에서 스택 노출
62
+ if (this.isDev && status >= 500) {
63
+ body.error.stack = err.stack;
64
+ }
65
+
66
+ // 추가 데이터
67
+ if (err.data && !(err instanceof ValidationError)) {
68
+ body.error.data = err.data;
69
+ }
70
+
71
+ ctx.status(status).json(body);
72
+ }
73
+
74
+ /**
75
+ * HTML 에러 응답 — 테마 에러 페이지 지원
76
+ *
77
+ * 검색 순서 (03-views-templates.md):
78
+ * 1. views/{theme}/errors/{code}.html
79
+ * 2. views/{theme}/errors/default.html
80
+ * 3. 프레임워크 내장 에러 페이지
81
+ *
82
+ * @private
83
+ */
84
+ _htmlResponse(err, ctx, status) {
85
+ const errorData = {
86
+ error: {
87
+ code: status,
88
+ message: err.message,
89
+ stack: this.isDev ? err.stack : null,
90
+ },
91
+ request: { url: ctx.url, method: ctx.method },
92
+ config: { debug: this.isDev },
93
+ };
94
+
95
+ // 테마 에러 페이지 시도 (View 렌더러가 있을 때)
96
+ const view = this._view || ctx.app?._view;
97
+ if (view) {
98
+ const theme = ctx.theme || ctx.app?.config?.get('themes.default', 'default') || 'default';
99
+ // 1. views/{theme}/errors/{code}.html
100
+ try {
101
+ const html = view.render(`errors/${status}`, errorData);
102
+ if (html && !html.startsWith('<!-- template:')) {
103
+ ctx.status(status).html(html);
104
+ return;
105
+ }
106
+ } catch {}
107
+ // 2. views/{theme}/errors/default.html
108
+ try {
109
+ const html = view.render('errors/default', errorData);
110
+ if (html && !html.startsWith('<!-- template:')) {
111
+ ctx.status(status).html(html);
112
+ return;
113
+ }
114
+ } catch {}
115
+ }
116
+
117
+ // 3. 프레임워크 내장 에러 페이지 (XSS 방지)
118
+ const esc = (s) => String(s)
119
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;')
120
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
121
+
122
+ const title = status >= 500 ? 'Server Error' : esc(err.message);
123
+
124
+ ctx.status(status).html(
125
+ `<!DOCTYPE html><html><head><meta charset="utf-8"><title>${status}</title>` +
126
+ `<style>body{font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:80px auto;` +
127
+ `padding:0 20px;color:#333}h1{color:#e74c3c;}pre{background:#f5f5f5;padding:16px;border-radius:8px;` +
128
+ `overflow-x:auto;font-size:13px;}</style></head>` +
129
+ `<body><h1>${status} — ${title}</h1>` +
130
+ `<p>요청: ${esc(ctx.method)} ${esc(ctx.url)}</p>` +
131
+ `${this.isDev && err.stack ? `<pre>${esc(err.stack)}</pre>` : ''}` +
132
+ `</body></html>`
133
+ );
134
+ }
135
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Middleware — 기본 미들웨어 클래스
3
+ *
4
+ * @see docs/framework/12-middleware.md
5
+ * @see docs/framework/class-design.mm.md (Middleware)
6
+ */
7
+ import Base from '../core/Base.js';
8
+
9
+ export default class Middleware extends Base {
10
+ /** @type {string} 미들웨어 등록 이름 (라우트에서 참조) */
11
+ static name = '';
12
+
13
+ /**
14
+ * 미들웨어 핸들러 (서브클래스에서 오버라이드)
15
+ * @param {import('./Context.js').default} ctx
16
+ * @param {Function} next
17
+ * @param {...*} params - 파라미터화 미들웨어 인자 ('name:param1:param2')
18
+ */
19
+ async handle(ctx, next, ...params) {
20
+ await next();
21
+ }
22
+ }
23
+
24
+ /**
25
+ * 미들웨어 체인 실행기
26
+ *
27
+ * @param {Array<Function>} middlewares - [(ctx, next) => ...] 배열
28
+ * @param {import('./Context.js').default} ctx
29
+ * @returns {Promise<void>}
30
+ */
31
+ export async function runMiddlewareChain(middlewares, ctx) {
32
+ let idx = 0;
33
+
34
+ async function next(err) {
35
+ if (err) throw err;
36
+ if (idx >= middlewares.length) return;
37
+
38
+ const fn = middlewares[idx++];
39
+ await fn(ctx, next);
40
+ }
41
+
42
+ await next();
43
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Router / RouteGroup — 라우트 등록 DSL
3
+ *
4
+ * @see docs/framework/01-routing-controllers.md
5
+ * @see docs/framework/class-design.mm.md (Router/RouteGroup)
6
+ */
7
+
8
+ export class RouteGroup {
9
+ /**
10
+ * @param {string} prefix - 그룹 접두사 ('', '/api')
11
+ * @param {object} [groupOpts] - 그룹 옵션 { middleware }
12
+ */
13
+ constructor(prefix = '', groupOpts = {}) {
14
+ this._prefix = prefix;
15
+ this._groupMiddleware = groupOpts.middleware || [];
16
+ this._routes = [];
17
+ }
18
+
19
+ /**
20
+ * @param {string} method
21
+ * @param {string} path
22
+ * @param {Function|object} handler
23
+ * @param {object} [opts]
24
+ */
25
+ _addRoute(method, path, handler, opts = {}) {
26
+ const fullPath = this._prefix + path;
27
+ this._routes.push({
28
+ method,
29
+ path: fullPath,
30
+ handler,
31
+ middleware: [...this._groupMiddleware, ...(opts.middleware || [])],
32
+ validate: opts.validate || null,
33
+ upload: opts.upload || null,
34
+ docs: opts.docs || null,
35
+ });
36
+ }
37
+
38
+ get(path, handler, opts) { this._addRoute('GET', path, handler, opts); }
39
+ post(path, handler, opts) { this._addRoute('POST', path, handler, opts); }
40
+ put(path, handler, opts) { this._addRoute('PUT', path, handler, opts); }
41
+ patch(path, handler, opts) { this._addRoute('PATCH', path, handler, opts); }
42
+ delete(path, handler, opts) { this._addRoute('DELETE', path, handler, opts); }
43
+
44
+ /**
45
+ * 그룹 — 중첩 라우트
46
+ * 인자 순서: (prefix, opts, callback) 또는 (prefix, callback, opts)
47
+ * @param {string} prefix
48
+ * @param {object|Function} optsOrCallback
49
+ * @param {Function|object} [callbackOrOpts]
50
+ */
51
+ group(prefix, optsOrCallback, callbackOrOpts) {
52
+ let opts, callback;
53
+ if (typeof optsOrCallback === 'function') {
54
+ // group('/api', (r) => {}, opts?)
55
+ callback = optsOrCallback;
56
+ opts = callbackOrOpts || {};
57
+ } else {
58
+ // group('/api', { middleware: [...] }, (r) => {})
59
+ opts = optsOrCallback || {};
60
+ callback = callbackOrOpts;
61
+ }
62
+
63
+ const subGroup = new RouteGroup(this._prefix + prefix, {
64
+ middleware: [...this._groupMiddleware, ...(opts.middleware || [])],
65
+ });
66
+ callback(subGroup);
67
+ this._routes.push(...subGroup._routes);
68
+ }
69
+
70
+ /**
71
+ * RESTful resource 자동 등록
72
+ * @param {string} name - 리소스 이름 (복수형, e.g. 'users')
73
+ * @param {object} controller - Controller 클래스 (메서드 레퍼런스)
74
+ * @param {object} [opts]
75
+ */
76
+ resource(name, controller, opts = {}) {
77
+ const basePath = `/${name}`;
78
+ const middleware = opts.middleware || [];
79
+ const base = { middleware };
80
+
81
+ if (controller.index) this.get(basePath, controller.index, base);
82
+ if (controller.store) this.post(basePath, controller.store, base);
83
+ if (controller.show) this.get(`${basePath}/:id`, controller.show, base);
84
+ if (controller.update) this.put(`${basePath}/:id`, controller.update, base);
85
+ if (controller.destroy) this.delete(`${basePath}/:id`, controller.destroy, base);
86
+ }
87
+ }
88
+
89
+ export default class Router extends RouteGroup {
90
+ constructor() {
91
+ super('');
92
+ }
93
+
94
+ /**
95
+ * 모든 등록된 라우트 반환
96
+ * @returns {Array<{method, path, handler, middleware, validate, upload, docs}>}
97
+ */
98
+ getRoutes() {
99
+ return this._routes;
100
+ }
101
+
102
+ /**
103
+ * 라우트 파일 로드 (routes/ callback 등록)
104
+ * @param {Function} routeCallback - (r: RouteGroup) => void
105
+ */
106
+ load(routeCallback) {
107
+ routeCallback(this);
108
+ }
109
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Validation — 간단문법 → Joi 변환 파서
3
+ *
4
+ * 'required|min:2|max:50' → Joi.string().required().min(2).max(50)
5
+ *
6
+ * @see docs/framework/09-validation.md
7
+ */
8
+ import { ValidationError } from '../core/AppError.js';
9
+
10
+ /**
11
+ * 간단 규칙 문자열을 Joi 스키마로 변환
12
+ * @param {object} rules - { name: 'required|min:2', email: 'required|email' }
13
+ * @param {object} Joi - Joi 모듈 참조
14
+ * @returns {import('joi').ObjectSchema}
15
+ */
16
+ export function parseRules(rules, Joi) {
17
+ const shape = {};
18
+
19
+ for (const [field, rule] of Object.entries(rules)) {
20
+ const parts = typeof rule === 'string' ? rule.split('|') : [];
21
+ let schema = Joi.any();
22
+
23
+ for (const part of parts) {
24
+ const [name, ...args] = part.split(':');
25
+
26
+ switch (name) {
27
+ case 'string':
28
+ schema = Joi.string(); break;
29
+ case 'number':
30
+ schema = Joi.number(); break;
31
+ case 'boolean':
32
+ schema = Joi.boolean(); break;
33
+ case 'required':
34
+ schema = schema.required(); break;
35
+ case 'optional':
36
+ schema = schema.optional(); break;
37
+ case 'min':
38
+ schema = schema.min(Number(args[0])); break;
39
+ case 'max':
40
+ schema = schema.max(Number(args[0])); break;
41
+ case 'email':
42
+ schema = Joi.string().email(); break;
43
+ case 'url':
44
+ schema = Joi.string().uri(); break;
45
+ case 'in':
46
+ schema = schema.valid(...args[0].split(',')); break;
47
+ case 'integer':
48
+ schema = schema.integer ? schema.integer() : Joi.number().integer(); break;
49
+ case 'positive':
50
+ schema = schema.positive ? schema.positive() : Joi.number().positive(); break;
51
+ case 'date':
52
+ schema = Joi.date(); break;
53
+ case 'alpha':
54
+ schema = Joi.string().alphanum(); break;
55
+ default:
56
+ // 알 수 없는 규칙은 무시
57
+ break;
58
+ }
59
+ }
60
+
61
+ shape[field] = schema;
62
+ }
63
+
64
+ return Joi.object(shape);
65
+ }
66
+
67
+ /**
68
+ * Validation 클래스 — 라우트 검증 실행기
69
+ */
70
+ export default class Validation {
71
+ /**
72
+ * @param {object} Joi - Joi 인스턴스
73
+ */
74
+ constructor(Joi) {
75
+ this._Joi = Joi;
76
+ }
77
+
78
+ /**
79
+ * 라우트 validate 옵션 실행
80
+ * @param {object} ctx
81
+ * @param {object} validateOpts - { body: schema, query: schema, params: schema }
82
+ * @returns {object} - { body, query, params } 정제된 값
83
+ */
84
+ run(ctx, validateOpts) {
85
+ const result = {};
86
+
87
+ if (validateOpts.body) {
88
+ result.body = this._validate(ctx.body, validateOpts.body);
89
+ }
90
+ if (validateOpts.query) {
91
+ result.query = this._validate(ctx.query, validateOpts.query);
92
+ }
93
+ if (validateOpts.params) {
94
+ result.params = this._validate(ctx.params, validateOpts.params);
95
+ }
96
+
97
+ return result;
98
+ }
99
+
100
+ /**
101
+ * @private
102
+ */
103
+ _validate(data, schema) {
104
+ // 간단문법 객체 → Joi 변환
105
+ if (schema && typeof schema === 'object' && !schema.validate) {
106
+ schema = parseRules(schema, this._Joi);
107
+ }
108
+
109
+ const { error, value } = schema.validate(data, {
110
+ abortEarly: false,
111
+ stripUnknown: true,
112
+ });
113
+
114
+ if (error) {
115
+ const fields = {};
116
+ for (const detail of error.details) {
117
+ fields[detail.path.join('.')] = detail.message;
118
+ }
119
+ throw new ValidationError('Validation failed', fields);
120
+ }
121
+
122
+ return value;
123
+ }
124
+ }