@gravito/monolith 1.0.0-alpha.6 → 1.0.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/CHANGELOG.md +9 -0
- package/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/index.cjs +428 -0
- package/dist/index.d.cts +168 -0
- package/dist/index.d.ts +168 -0
- package/dist/index.js +309 -5521
- package/ion/src/index.js +2 -2
- package/package.json +29 -15
- package/src/ContentManager.ts +76 -6
- package/src/Controller.ts +82 -0
- package/src/FormRequest.ts +86 -0
- package/src/Router.ts +35 -0
- package/src/Sanitizer.ts +32 -0
- package/src/index.ts +6 -2
- package/src/middleware/TrimStrings.ts +39 -0
- package/tests/ecommerce_integration.test.ts +58 -0
- package/tests/extra.test.ts +135 -0
- package/tests/mvc.test.ts +150 -0
- package/tsconfig.json +3 -1
package/ion/src/index.js
CHANGED
|
@@ -2384,7 +2384,7 @@ var require_cjs = __commonJS((exports, module) => {
|
|
|
2384
2384
|
});
|
|
2385
2385
|
// ../core/package.json
|
|
2386
2386
|
var package_default = {
|
|
2387
|
-
name: "gravito
|
|
2387
|
+
name: "@gravito/core",
|
|
2388
2388
|
version: "1.0.0-beta.2",
|
|
2389
2389
|
description: "",
|
|
2390
2390
|
module: "./dist/index.mjs",
|
|
@@ -3082,7 +3082,7 @@ async function handleProcessError(kind, error) {
|
|
|
3082
3082
|
}
|
|
3083
3083
|
}));
|
|
3084
3084
|
} catch (e) {
|
|
3085
|
-
console.error("[gravito
|
|
3085
|
+
console.error("[@gravito/core] Failed to handle process-level error:", e);
|
|
3086
3086
|
} finally {
|
|
3087
3087
|
if (shouldExit) {
|
|
3088
3088
|
clearTimeout(exitTimer);
|
package/package.json
CHANGED
|
@@ -1,32 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravito/monolith",
|
|
3
|
-
"version": "1.0.0
|
|
4
|
-
"description": "
|
|
5
|
-
"main": "dist/index.
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Enterprise monolith framework for Gravito Galaxy",
|
|
5
|
+
"main": "dist/index.cjs",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"type": "module",
|
|
6
8
|
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
7
16
|
"scripts": {
|
|
8
|
-
"build": "
|
|
9
|
-
"test": "bun test"
|
|
17
|
+
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"test:coverage": "bun test --coverage --coverage-threshold=80",
|
|
20
|
+
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
|
|
10
21
|
},
|
|
11
22
|
"keywords": [
|
|
12
23
|
"gravito",
|
|
13
24
|
"orbit",
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"cms"
|
|
25
|
+
"monolith",
|
|
26
|
+
"backend"
|
|
17
27
|
],
|
|
18
28
|
"author": "Carl Lee <carllee0520@gmail.com>",
|
|
19
29
|
"license": "MIT",
|
|
20
|
-
"peerDependencies": {
|
|
21
|
-
"gravito-core": "1.0.0-beta.5"
|
|
22
|
-
},
|
|
23
30
|
"dependencies": {
|
|
24
31
|
"marked": "^11.1.1",
|
|
25
|
-
"gray-matter": "^4.0.3"
|
|
32
|
+
"gray-matter": "^4.0.3",
|
|
33
|
+
"@gravito/mass": "workspace:*"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@gravito/core": "workspace:*"
|
|
26
37
|
},
|
|
27
38
|
"devDependencies": {
|
|
28
|
-
"
|
|
29
|
-
"@types/marked": "^5.0.0"
|
|
39
|
+
"@gravito/core": "workspace:*",
|
|
40
|
+
"@types/marked": "^5.0.0",
|
|
41
|
+
"bun-types": "^1.3.5",
|
|
42
|
+
"tsup": "^8.5.1",
|
|
43
|
+
"typescript": "^5.9.3"
|
|
30
44
|
},
|
|
31
45
|
"publishConfig": {
|
|
32
46
|
"access": "public"
|
|
@@ -37,4 +51,4 @@
|
|
|
37
51
|
"url": "git+https://github.com/gravito-framework/gravito.git",
|
|
38
52
|
"directory": "packages/monolith"
|
|
39
53
|
}
|
|
40
|
-
}
|
|
54
|
+
}
|
package/src/ContentManager.ts
CHANGED
|
@@ -20,6 +20,19 @@ export class ContentManager {
|
|
|
20
20
|
private collections = new Map<string, CollectionConfig>()
|
|
21
21
|
// Simple memory cache: collection:locale:slug -> ContentItem
|
|
22
22
|
private cache = new Map<string, ContentItem>()
|
|
23
|
+
private renderer = (() => {
|
|
24
|
+
const renderer = new marked.Renderer()
|
|
25
|
+
renderer.html = (html: string) => this.escapeHtml(html)
|
|
26
|
+
renderer.link = (href: string | null, title: string | null, text: string) => {
|
|
27
|
+
if (!href || !this.isSafeUrl(href)) {
|
|
28
|
+
return text
|
|
29
|
+
}
|
|
30
|
+
const safeHref = this.escapeHtml(href)
|
|
31
|
+
const titleAttr = title ? ` title="${this.escapeHtml(title)}"` : ''
|
|
32
|
+
return `<a href="${safeHref}"${titleAttr}>${text}</a>`
|
|
33
|
+
}
|
|
34
|
+
return renderer
|
|
35
|
+
})()
|
|
23
36
|
|
|
24
37
|
/**
|
|
25
38
|
* Create a new ContentManager instance.
|
|
@@ -53,6 +66,12 @@ export class ContentManager {
|
|
|
53
66
|
throw new Error(`Collection '${collectionName}' not defined`)
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
const safeSlug = this.sanitizeSegment(slug)
|
|
70
|
+
const safeLocale = this.sanitizeSegment(locale)
|
|
71
|
+
if (!safeSlug || !safeLocale) {
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
const cacheKey = `${collectionName}:${locale}:${slug}`
|
|
57
76
|
if (this.cache.has(cacheKey)) {
|
|
58
77
|
return this.cache.get(cacheKey)!
|
|
@@ -60,7 +79,7 @@ export class ContentManager {
|
|
|
60
79
|
|
|
61
80
|
// Determine path strategy
|
|
62
81
|
// Strategy: {root}/{path}/{locale}/{slug}.md
|
|
63
|
-
const filePath = join(this.rootDir, config.path,
|
|
82
|
+
const filePath = join(this.rootDir, config.path, safeLocale, `${safeSlug}.md`)
|
|
64
83
|
|
|
65
84
|
try {
|
|
66
85
|
const exists = await stat(filePath)
|
|
@@ -71,16 +90,16 @@ export class ContentManager {
|
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
const fileContent = await readFile(filePath, 'utf-8')
|
|
74
|
-
const { data, content,
|
|
93
|
+
const { data, content, excerpt } = matter(fileContent)
|
|
75
94
|
|
|
76
|
-
const html = await marked.parse(content)
|
|
95
|
+
const html = await marked.parse(content, { renderer: this.renderer })
|
|
77
96
|
|
|
78
97
|
const item: ContentItem = {
|
|
79
98
|
slug,
|
|
80
99
|
body: html,
|
|
81
100
|
meta: data,
|
|
82
101
|
raw: content,
|
|
83
|
-
excerpt:
|
|
102
|
+
excerpt: excerpt,
|
|
84
103
|
}
|
|
85
104
|
|
|
86
105
|
this.cache.set(cacheKey, item)
|
|
@@ -106,7 +125,12 @@ export class ContentManager {
|
|
|
106
125
|
throw new Error(`Collection '${collectionName}' not defined`)
|
|
107
126
|
}
|
|
108
127
|
|
|
109
|
-
const
|
|
128
|
+
const safeLocale = this.sanitizeSegment(locale)
|
|
129
|
+
if (!safeLocale) {
|
|
130
|
+
return []
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const dirPath = join(this.rootDir, config.path, safeLocale)
|
|
110
134
|
|
|
111
135
|
try {
|
|
112
136
|
const files = await readdir(dirPath)
|
|
@@ -117,7 +141,7 @@ export class ContentManager {
|
|
|
117
141
|
continue
|
|
118
142
|
}
|
|
119
143
|
const slug = parse(file).name
|
|
120
|
-
const item = await this.find(collectionName, slug,
|
|
144
|
+
const item = await this.find(collectionName, slug, safeLocale)
|
|
121
145
|
if (item) {
|
|
122
146
|
items.push(item)
|
|
123
147
|
}
|
|
@@ -129,4 +153,50 @@ export class ContentManager {
|
|
|
129
153
|
return []
|
|
130
154
|
}
|
|
131
155
|
}
|
|
156
|
+
|
|
157
|
+
private sanitizeSegment(value: string): string | null {
|
|
158
|
+
if (!value) {
|
|
159
|
+
return null
|
|
160
|
+
}
|
|
161
|
+
if (value.includes('\0')) {
|
|
162
|
+
return null
|
|
163
|
+
}
|
|
164
|
+
if (value.includes('/') || value.includes('\\')) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
if (value.includes('..')) {
|
|
168
|
+
return null
|
|
169
|
+
}
|
|
170
|
+
return value
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private escapeHtml(value: string): string {
|
|
174
|
+
return value
|
|
175
|
+
.replace(/&/g, '&')
|
|
176
|
+
.replace(/</g, '<')
|
|
177
|
+
.replace(/>/g, '>')
|
|
178
|
+
.replace(/"/g, '"')
|
|
179
|
+
.replace(/'/g, ''')
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private isSafeUrl(href: string): boolean {
|
|
183
|
+
const trimmed = href.trim()
|
|
184
|
+
if (!trimmed) {
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
const lower = trimmed.toLowerCase()
|
|
188
|
+
if (
|
|
189
|
+
lower.startsWith('javascript:') ||
|
|
190
|
+
lower.startsWith('vbscript:') ||
|
|
191
|
+
lower.startsWith('data:')
|
|
192
|
+
) {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
const schemeMatch = lower.match(/^[a-z][a-z0-9+.-]*:/)
|
|
196
|
+
if (!schemeMatch) {
|
|
197
|
+
return true
|
|
198
|
+
}
|
|
199
|
+
const scheme = schemeMatch[0]
|
|
200
|
+
return scheme === 'http:' || scheme === 'https:' || scheme === 'mailto:'
|
|
201
|
+
}
|
|
132
202
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { GravitoContext } from '@gravito/core'
|
|
2
|
+
import { Sanitizer } from './Sanitizer.js'
|
|
3
|
+
|
|
4
|
+
export abstract class BaseController {
|
|
5
|
+
protected sanitizer = new Sanitizer()
|
|
6
|
+
|
|
7
|
+
async call(ctx: GravitoContext, method: string): Promise<Response> {
|
|
8
|
+
const action = (this as any)[method] as (ctx: GravitoContext) => Promise<Response>
|
|
9
|
+
if (typeof action !== 'function') {
|
|
10
|
+
throw new Error(`Method ${method} not found on controller`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return await action.apply(this, [ctx])
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export abstract class Controller {
|
|
18
|
+
protected context!: GravitoContext
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Set the request context for this controller instance.
|
|
22
|
+
*/
|
|
23
|
+
public setContext(context: GravitoContext): this {
|
|
24
|
+
this.context = context
|
|
25
|
+
return this
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return a JSON response.
|
|
30
|
+
*/
|
|
31
|
+
protected json(data: any, status = 200) {
|
|
32
|
+
return this.context.json(data, status as any)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Return a text response.
|
|
37
|
+
*/
|
|
38
|
+
protected text(text: string, status = 200) {
|
|
39
|
+
return this.context.text(text, status as any)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Redirect to a given URL.
|
|
44
|
+
*/
|
|
45
|
+
protected redirect(url: string, status = 302) {
|
|
46
|
+
return this.context.redirect(url, status as any)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get an item from the context variables.
|
|
51
|
+
*/
|
|
52
|
+
protected get(key: string): any {
|
|
53
|
+
return this.context.get(key as any)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get the request object.
|
|
58
|
+
*/
|
|
59
|
+
protected get request() {
|
|
60
|
+
return this.context.req
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Validate the request against a schema.
|
|
65
|
+
*/
|
|
66
|
+
protected async validate(_schema: any, source: 'json' | 'query' | 'form' = 'json'): Promise<any> {
|
|
67
|
+
const req = this.context.req as any
|
|
68
|
+
return req.valid(source)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a controller action into a handler compatible with GravitoContext.
|
|
73
|
+
*/
|
|
74
|
+
public static call(method: string): any {
|
|
75
|
+
return async (c: GravitoContext) => {
|
|
76
|
+
const instance = new (this as any)()
|
|
77
|
+
instance.setContext(c)
|
|
78
|
+
const action = instance[method] as (ctx: GravitoContext) => Promise<Response>
|
|
79
|
+
return action.apply(instance, [c])
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { GravitoContext, GravitoNext } from '@gravito/core'
|
|
2
|
+
import { type TSchema, validate } from '@gravito/mass'
|
|
3
|
+
import { Sanitizer } from './Sanitizer.js'
|
|
4
|
+
|
|
5
|
+
export abstract class FormRequest {
|
|
6
|
+
protected context!: GravitoContext
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Define the validation schema.
|
|
10
|
+
*/
|
|
11
|
+
abstract schema(): TSchema
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Define the source of data (json, query, form, etc.).
|
|
15
|
+
*/
|
|
16
|
+
source(): 'json' | 'query' | 'form' {
|
|
17
|
+
return 'json'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Determine if the user is authorized to make this request.
|
|
22
|
+
*/
|
|
23
|
+
authorize(): boolean {
|
|
24
|
+
return true
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Set the context.
|
|
29
|
+
*/
|
|
30
|
+
public setContext(context: GravitoContext): this {
|
|
31
|
+
this.context = context
|
|
32
|
+
return this
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get the validated data.
|
|
37
|
+
*/
|
|
38
|
+
public validated(): any {
|
|
39
|
+
const data = (this.context.req as any).valid(this.source())
|
|
40
|
+
return Sanitizer.clean(data)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Static helper to create a middleware from this request class.
|
|
45
|
+
*/
|
|
46
|
+
public static middleware(): any {
|
|
47
|
+
const RequestClass = this as any
|
|
48
|
+
|
|
49
|
+
return async (c: GravitoContext, next: GravitoNext) => {
|
|
50
|
+
const instance = new RequestClass()
|
|
51
|
+
instance.setContext(c)
|
|
52
|
+
|
|
53
|
+
// 1. Authorization Check
|
|
54
|
+
if (!instance.authorize()) {
|
|
55
|
+
return c.json({ message: 'This action is unauthorized.' }, 403)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. Validation using mass
|
|
59
|
+
// Use double cast to bypass strict Hono Context type matching in CI
|
|
60
|
+
const validator = validate(instance.source(), instance.schema(), (result: any, ctx: any) => {
|
|
61
|
+
if (!result.success) {
|
|
62
|
+
const errors: Record<string, string[]> = {}
|
|
63
|
+
const issues = result.error?.issues || []
|
|
64
|
+
|
|
65
|
+
for (const issue of issues) {
|
|
66
|
+
const path = Array.isArray(issue.path) ? issue.path.join('.') : issue.path || 'root'
|
|
67
|
+
const key = path.replace(/^\//, '').replace(/\//g, '.')
|
|
68
|
+
if (!errors[key]) errors[key] = []
|
|
69
|
+
errors[key].push(issue.message || 'Validation failed')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return ctx.json(
|
|
73
|
+
{
|
|
74
|
+
message: 'The given data was invalid.',
|
|
75
|
+
errors: Object.keys(errors).length > 0 ? errors : { root: ['Validation failed'] },
|
|
76
|
+
},
|
|
77
|
+
422
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// Forced cast to any to ensure CI compatibility
|
|
83
|
+
return (validator as any)(c as any, next as any)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/Router.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Hono } from 'hono'
|
|
2
|
+
|
|
3
|
+
export class RouterHelper {
|
|
4
|
+
/**
|
|
5
|
+
* Register standard resource routes for a controller.
|
|
6
|
+
*
|
|
7
|
+
* GET /prefix -> index
|
|
8
|
+
* GET /prefix/create -> create
|
|
9
|
+
* POST /prefix -> store
|
|
10
|
+
* GET /prefix/:id -> show
|
|
11
|
+
* GET /prefix/:id/edit -> edit
|
|
12
|
+
* PUT /prefix/:id -> update
|
|
13
|
+
* DELETE /prefix/:id -> destroy
|
|
14
|
+
*/
|
|
15
|
+
public static resource(app: Hono<any, any, any>, prefix: string, controller: any) {
|
|
16
|
+
const p = prefix.startsWith('/') ? prefix : `/${prefix}`
|
|
17
|
+
|
|
18
|
+
// Mapping: Method -> [HTTP Verb, Path Suffix]
|
|
19
|
+
const routes: Record<string, [string, string]> = {
|
|
20
|
+
index: ['get', ''],
|
|
21
|
+
create: ['get', '/create'],
|
|
22
|
+
store: ['post', ''],
|
|
23
|
+
show: ['get', '/:id'],
|
|
24
|
+
edit: ['get', '/:id/edit'],
|
|
25
|
+
update: ['put', '/:id'],
|
|
26
|
+
destroy: ['delete', '/:id'],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const [method, [verb, suffix]] of Object.entries(routes)) {
|
|
30
|
+
if (typeof controller.prototype[method] === 'function') {
|
|
31
|
+
;(app as any)[verb](`${p}${suffix}`, controller.call(method))
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/Sanitizer.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility to clean up request data.
|
|
3
|
+
*/
|
|
4
|
+
export class Sanitizer {
|
|
5
|
+
/**
|
|
6
|
+
* Recursively trim strings and convert empty strings to null.
|
|
7
|
+
*/
|
|
8
|
+
public static clean(data: any): any {
|
|
9
|
+
if (!data || typeof data !== 'object') {
|
|
10
|
+
return data
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const cleaned: any = Array.isArray(data) ? [] : {}
|
|
14
|
+
|
|
15
|
+
for (const key in data) {
|
|
16
|
+
let value = data[key]
|
|
17
|
+
|
|
18
|
+
if (typeof value === 'string') {
|
|
19
|
+
value = value.trim()
|
|
20
|
+
if (value === '') {
|
|
21
|
+
value = null
|
|
22
|
+
}
|
|
23
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
24
|
+
value = this.clean(value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
cleaned[key] = value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return cleaned
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { GravitoOrbit, PlanetCore } from 'gravito
|
|
1
|
+
import type { GravitoOrbit, PlanetCore } from '@gravito/core'
|
|
2
2
|
import { type CollectionConfig, ContentManager } from './ContentManager'
|
|
3
3
|
|
|
4
|
-
declare module 'gravito
|
|
4
|
+
declare module '@gravito/core' {
|
|
5
5
|
interface Variables {
|
|
6
6
|
content: ContentManager
|
|
7
7
|
}
|
|
@@ -40,4 +40,8 @@ export class OrbitMonolith implements GravitoOrbit {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export { Schema } from '@gravito/mass'
|
|
43
44
|
export * from './ContentManager'
|
|
45
|
+
export * from './Controller'
|
|
46
|
+
export * from './FormRequest'
|
|
47
|
+
export { RouterHelper as Route } from './Router'
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from 'hono'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Automatically trim all strings in the request body and query.
|
|
5
|
+
*/
|
|
6
|
+
export const trimStrings = (): MiddlewareHandler => {
|
|
7
|
+
return async (c, next) => {
|
|
8
|
+
// We proxy the req.json and req.query methods to return trimmed data
|
|
9
|
+
// This is more efficient than pre-processing everything if the controller doesn't use it.
|
|
10
|
+
|
|
11
|
+
// However, for best DX, we will attempt to clean the body if it's JSON
|
|
12
|
+
if (c.req.header('Content-Type')?.includes('application/json')) {
|
|
13
|
+
try {
|
|
14
|
+
const body = await c.req.json()
|
|
15
|
+
clean(body, (val) => (typeof val === 'string' ? val.trim() : val))
|
|
16
|
+
// Since Hono's body is already read, we might need to store it in context
|
|
17
|
+
// But for now, let's assume we use a simpler approach for the prototype.
|
|
18
|
+
} catch {
|
|
19
|
+
// Skip if not valid JSON
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
await next()
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clean(obj: any, transform: (val: any) => any) {
|
|
28
|
+
if (!obj || typeof obj !== 'object') {
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const key in obj) {
|
|
33
|
+
if (typeof obj[key] === 'object') {
|
|
34
|
+
clean(obj[key], transform)
|
|
35
|
+
} else {
|
|
36
|
+
obj[key] = transform(obj[key])
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { Photon } from '@gravito/photon'
|
|
3
|
+
import { Controller, FormRequest, Schema } from '../src'
|
|
4
|
+
|
|
5
|
+
// --- 模擬電商控制器與請求 ---
|
|
6
|
+
|
|
7
|
+
class StoreProductRequest extends FormRequest {
|
|
8
|
+
schema() {
|
|
9
|
+
return Schema.Object({
|
|
10
|
+
name: Schema.String({ minLength: 3 }),
|
|
11
|
+
price: Schema.Number({ minimum: 0 }),
|
|
12
|
+
})
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
class ProductController extends Controller {
|
|
17
|
+
async store() {
|
|
18
|
+
const request = new StoreProductRequest().setContext(this.context)
|
|
19
|
+
const data = request.validated()
|
|
20
|
+
return this.json({ success: true, product: data }, 201)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// --- 測試 ---
|
|
25
|
+
|
|
26
|
+
describe('Ecommerce MVC Integration (Shared Environment)', () => {
|
|
27
|
+
let app: Photon
|
|
28
|
+
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
app = new Photon()
|
|
31
|
+
// 手動註冊路由以驗證
|
|
32
|
+
app.post('/products', StoreProductRequest.middleware(), ProductController.call('store'))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('should return 422 when data is invalid', async () => {
|
|
36
|
+
const res = await app.request('/products', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: { 'Content-Type': 'application/json' },
|
|
39
|
+
body: JSON.stringify({ name: 'Hi', price: -1 }),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(res.status).toBe(422)
|
|
43
|
+
const data: any = await res.json()
|
|
44
|
+
expect(data.message).toBe('The given data was invalid.')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should return 201 and sanitized data when valid', async () => {
|
|
48
|
+
const res = await app.request('/products', {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'Content-Type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({ name: ' Galaxy Pro ', price: 999 }),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
expect(res.status).toBe(201)
|
|
55
|
+
const data: any = await res.json()
|
|
56
|
+
expect(data.product.name).toBe('Galaxy Pro') // 驗證自動 Trim
|
|
57
|
+
})
|
|
58
|
+
})
|