@kava/kava-api-core 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/bun.lock +160 -0
- package/dist/auth.util.d.ts +23 -0
- package/dist/auth.util.js +175 -0
- package/dist/auth.util.js.map +1 -0
- package/dist/context.util.d.ts +7 -0
- package/dist/context.util.js +11 -0
- package/dist/context.util.js.map +1 -0
- package/dist/controller.util.d.ts +118 -0
- package/dist/controller.util.js +144 -0
- package/dist/controller.util.js.map +1 -0
- package/dist/conversion.util.d.ts +8 -0
- package/dist/conversion.util.js +52 -0
- package/dist/conversion.util.js.map +1 -0
- package/dist/db.util.d.ts +80 -0
- package/dist/db.util.js +166 -0
- package/dist/db.util.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.util.d.ts +30 -0
- package/dist/logger.util.js +117 -0
- package/dist/logger.util.js.map +1 -0
- package/dist/mail.util.d.ts +21 -0
- package/dist/mail.util.js +53 -0
- package/dist/mail.util.js.map +1 -0
- package/dist/middleware.util.d.ts +263 -0
- package/dist/middleware.util.js +233 -0
- package/dist/middleware.util.js.map +1 -0
- package/dist/model.util.d.ts +204 -0
- package/dist/model.util.js +1495 -0
- package/dist/model.util.js.map +1 -0
- package/dist/permission.util.d.ts +38 -0
- package/dist/permission.util.js +91 -0
- package/dist/permission.util.js.map +1 -0
- package/dist/route.util.d.ts +1 -0
- package/dist/route.util.js +12 -0
- package/dist/route.util.js.map +1 -0
- package/dist/storage.util.d.ts +56 -0
- package/dist/storage.util.js +82 -0
- package/dist/storage.util.js.map +1 -0
- package/dist/validation.util.d.ts +7 -0
- package/dist/validation.util.js +237 -0
- package/dist/validation.util.js.map +1 -0
- package/package.json +34 -0
- package/src/auth.util.ts +242 -0
- package/src/context.util.ts +17 -0
- package/src/controller.util.ts +237 -0
- package/src/conversion.util.ts +65 -0
- package/src/db.util.ts +405 -0
- package/src/index.ts +13 -0
- package/src/logger.util.ts +170 -0
- package/src/mail.util.ts +86 -0
- package/src/middleware.util.ts +289 -0
- package/src/model.util.ts +2211 -0
- package/src/permission.util.ts +136 -0
- package/src/route.util.ts +12 -0
- package/src/storage.util.ts +102 -0
- package/src/validation.util.ts +338 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { ControllerContext } from "elysia"
|
|
2
|
+
|
|
3
|
+
type KeyDigit = "0"|"1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9"
|
|
4
|
+
export type KeyFeature = `${"1"|"2"|"3"|"4"|"5"|"6"|"7"|"8"|"9"}${KeyDigit}${KeyDigit}`
|
|
5
|
+
export type KeyAccess = `${KeyDigit}${KeyDigit}`
|
|
6
|
+
export type KeyPermission = `${KeyFeature}.${KeyAccess}` | KeyAccess
|
|
7
|
+
|
|
8
|
+
const features = new Map<KeyFeature, { key: KeyFeature; name: string }>()
|
|
9
|
+
const accesses = new Map<KeyAccess, any>()
|
|
10
|
+
|
|
11
|
+
type FeatureAccess = Partial<Record<KeyFeature, {
|
|
12
|
+
name: string
|
|
13
|
+
accesses: Partial<Record<KeyAccess, string>>
|
|
14
|
+
}>>
|
|
15
|
+
|
|
16
|
+
export const permission = {
|
|
17
|
+
register: (def: FeatureAccess) => {
|
|
18
|
+
const featureAccessMap = new Map<string, string>()
|
|
19
|
+
let defaultFeature: KeyFeature | null = null
|
|
20
|
+
|
|
21
|
+
for (const [featureKey, feature] of Object.entries(def)) {
|
|
22
|
+
if (!defaultFeature) defaultFeature = featureKey as KeyFeature
|
|
23
|
+
|
|
24
|
+
registerFeature({
|
|
25
|
+
key: featureKey as KeyFeature,
|
|
26
|
+
name: feature.name
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
for (const [accessKey, accessName] of Object.entries(feature.accesses)) {
|
|
30
|
+
const permKey =
|
|
31
|
+
`${featureKey}.${String(accessKey).padStart(2, "0")}`
|
|
32
|
+
|
|
33
|
+
registerAccess({
|
|
34
|
+
featureKey,
|
|
35
|
+
accessKey,
|
|
36
|
+
accessName,
|
|
37
|
+
permKey
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
featureAccessMap.set(
|
|
41
|
+
`${featureKey}.${accessKey}`,
|
|
42
|
+
permKey
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return createScopeApi(defaultFeature!)
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
getFeatures: () => [...features.values()],
|
|
51
|
+
|
|
52
|
+
getAccesses: () => {
|
|
53
|
+
const result: Record<string, {
|
|
54
|
+
key: string
|
|
55
|
+
name: string
|
|
56
|
+
accesses: { key: string; name: string }[]
|
|
57
|
+
}> = {}
|
|
58
|
+
|
|
59
|
+
for (const feature of features.values()) {
|
|
60
|
+
result[String(feature.key)] = {
|
|
61
|
+
key: String(feature.key),
|
|
62
|
+
name: feature.name,
|
|
63
|
+
accesses: []
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const access of accesses.values()) {
|
|
68
|
+
const featureKey = String(access.featureKey)
|
|
69
|
+
|
|
70
|
+
if (!result[featureKey]) continue
|
|
71
|
+
|
|
72
|
+
result[featureKey].accesses.push({
|
|
73
|
+
key: String(access.accessKey).padStart(2, "0"),
|
|
74
|
+
name: access.accessName
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return Object.values(result)
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
function normalize(
|
|
84
|
+
raw: KeyPermission,
|
|
85
|
+
defaultFeature?: KeyFeature
|
|
86
|
+
): KeyPermission {
|
|
87
|
+
if (!raw.includes(".") && defaultFeature) {
|
|
88
|
+
return `${defaultFeature}.${String(raw).padStart(2, "0") as KeyAccess}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return raw
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function registerFeature(f: { key: KeyFeature; name: string }) {
|
|
95
|
+
if (!features.has(f.key)) {
|
|
96
|
+
features.set(f.key, f)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function registerAccess(a: any) {
|
|
101
|
+
if (!accesses.has(a.permKey)) {
|
|
102
|
+
accesses.set(a.permKey, a)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createPermission(keys: KeyPermission[]) {
|
|
107
|
+
return {
|
|
108
|
+
keys,
|
|
109
|
+
|
|
110
|
+
orHave(raw: KeyPermission) {
|
|
111
|
+
return createPermission([
|
|
112
|
+
...this.keys,
|
|
113
|
+
normalize(raw) as KeyPermission
|
|
114
|
+
])
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
guard(c: ControllerContext) {
|
|
118
|
+
const permissions = new Set(c.permissions || [])
|
|
119
|
+
|
|
120
|
+
const ok = this.keys.some(k => permissions?.has(k))
|
|
121
|
+
if (!ok) {
|
|
122
|
+
c.responseForbidden()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function createScopeApi(defaultFeature: KeyFeature) {
|
|
129
|
+
return {
|
|
130
|
+
have(raw: KeyPermission) {
|
|
131
|
+
const key = normalize(raw, defaultFeature)
|
|
132
|
+
return createPermission([key])
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// ================================>
|
|
2
|
+
// ## Route: Basic api routers
|
|
3
|
+
// ================================>
|
|
4
|
+
export function api(app: any, basePath: string, controller: any) {
|
|
5
|
+
return app.group(basePath, (group: any) => group
|
|
6
|
+
.get("/", controller.index)
|
|
7
|
+
.post("/", controller.store)
|
|
8
|
+
.get("/:id", controller.show)
|
|
9
|
+
.put("/:id", controller.update)
|
|
10
|
+
.delete("/:id", controller.destroy)
|
|
11
|
+
)
|
|
12
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { Elysia } from "elysia";
|
|
4
|
+
import { db } from "@utils/db.util";
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
// ================================>
|
|
9
|
+
// ## Storage: Middleware storage handler
|
|
10
|
+
// ================================>
|
|
11
|
+
export const storage = (app: Elysia) => app.get("/storage/*", async ({ params, set, user }: Record<string, any>) => {
|
|
12
|
+
const requestedPath = params["*"];
|
|
13
|
+
const baseDir = path.resolve("storage", "public");
|
|
14
|
+
const targetPath = path.resolve(baseDir, requestedPath);
|
|
15
|
+
|
|
16
|
+
if (!targetPath.startsWith(baseDir)) {
|
|
17
|
+
set.status = 400;
|
|
18
|
+
return { error: "Invalid path" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (fs.existsSync(targetPath)) {
|
|
22
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
23
|
+
const mimeTypes: Record<string, string> = {
|
|
24
|
+
".jpg" : "image/jpeg",
|
|
25
|
+
".jpeg" : "image/jpeg",
|
|
26
|
+
".png" : "image/png",
|
|
27
|
+
".webp" : "image/webp",
|
|
28
|
+
".gif" : "image/gif",
|
|
29
|
+
".pdf" : "application/pdf",
|
|
30
|
+
".txt" : "text/plain",
|
|
31
|
+
".json" : "application/json",
|
|
32
|
+
".svg" : "image/svg+xml",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const buffer = fs.readFileSync(targetPath);
|
|
36
|
+
|
|
37
|
+
set.headers["Content-Type"] = mimeTypes[ext] || "application/octet-stream";
|
|
38
|
+
set.headers["Content-Length"] = buffer.length.toString();
|
|
39
|
+
|
|
40
|
+
return new Response(buffer);
|
|
41
|
+
} else {
|
|
42
|
+
const baseDir = path.resolve("storage", "private");
|
|
43
|
+
const targetPath = path.resolve(baseDir, requestedPath);
|
|
44
|
+
|
|
45
|
+
if (fs.existsSync(targetPath)) {
|
|
46
|
+
if (!user) {
|
|
47
|
+
set.status = 404
|
|
48
|
+
return { error: "File not found" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const file = await db("storages").where({ path: requestedPath, disk: "private" }).first()
|
|
52
|
+
|
|
53
|
+
if (!file) {
|
|
54
|
+
set.status = 404
|
|
55
|
+
return { error: "File not found" }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let hasAccess = file.user_id === user.id
|
|
59
|
+
|
|
60
|
+
if (!hasAccess) {
|
|
61
|
+
hasAccess = await db("storage_permissions").where("storage_id", file.id)
|
|
62
|
+
.andWhere((q) => {
|
|
63
|
+
q.where("user_id", user.id)
|
|
64
|
+
.orWhere("role_id", user.role_id)
|
|
65
|
+
})
|
|
66
|
+
.first().then(Boolean)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if (!hasAccess) {
|
|
71
|
+
set.status = 404
|
|
72
|
+
return { error: "File not found" }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
} else {
|
|
76
|
+
set.status = 404;
|
|
77
|
+
return { error: "File not found" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
81
|
+
const mimeTypes: Record<string, string> = {
|
|
82
|
+
".jpg" : "image/jpeg",
|
|
83
|
+
".jpeg" : "image/jpeg",
|
|
84
|
+
".png" : "image/png",
|
|
85
|
+
".webp" : "image/webp",
|
|
86
|
+
".gif" : "image/gif",
|
|
87
|
+
".pdf" : "application/pdf",
|
|
88
|
+
".txt" : "text/plain",
|
|
89
|
+
".json" : "application/json",
|
|
90
|
+
".svg" : "image/svg+xml",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const buffer = fs.readFileSync(targetPath);
|
|
94
|
+
|
|
95
|
+
set.headers["Content-Type"] = mimeTypes[ext] || "application/octet-stream";
|
|
96
|
+
set.headers["Content-Length"] = buffer.length.toString();
|
|
97
|
+
|
|
98
|
+
return new Response(buffer);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
});
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import validator from "validator"
|
|
2
|
+
import { db } from "@utils"
|
|
3
|
+
|
|
4
|
+
// ==========================>
|
|
5
|
+
// ## Validation: Rules of validation
|
|
6
|
+
// ==========================>
|
|
7
|
+
|
|
8
|
+
export type ValidationRule =
|
|
9
|
+
| "required"
|
|
10
|
+
| "string"
|
|
11
|
+
| "numeric"
|
|
12
|
+
| "number"
|
|
13
|
+
| "boolean"
|
|
14
|
+
| "email"
|
|
15
|
+
| "url"
|
|
16
|
+
| "date"
|
|
17
|
+
| "confirmed"
|
|
18
|
+
| "array"
|
|
19
|
+
| `min:`
|
|
20
|
+
| `min:${number}`
|
|
21
|
+
| `max:`
|
|
22
|
+
| `max:${number}`
|
|
23
|
+
| `between:`
|
|
24
|
+
| `between:${number},${number}`
|
|
25
|
+
| `in:`
|
|
26
|
+
| `in:${string}`
|
|
27
|
+
| `not_in:`
|
|
28
|
+
| `not_in:${string}`
|
|
29
|
+
| `same:`
|
|
30
|
+
| `same:${string}`
|
|
31
|
+
| `different:`
|
|
32
|
+
| `different:${string}`
|
|
33
|
+
| `regex:`
|
|
34
|
+
| `regex:${string}`
|
|
35
|
+
| `unique:`
|
|
36
|
+
| `unique:${string},${string}`
|
|
37
|
+
| `exists:`
|
|
38
|
+
| `exists:${string},${string}`
|
|
39
|
+
|
|
40
|
+
export type ValidationRules = Record<string, ValidationRule[] | string>
|
|
41
|
+
|
|
42
|
+
export interface ValidationResult {
|
|
43
|
+
valid : boolean
|
|
44
|
+
errors : Record<string, string[]>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ==================================>
|
|
48
|
+
// ## Check validate field from rules
|
|
49
|
+
// ==================================>
|
|
50
|
+
export async function validate(
|
|
51
|
+
data: Record<string, any>,
|
|
52
|
+
rules: ValidationRules
|
|
53
|
+
): Promise<ValidationResult> {
|
|
54
|
+
const errors: Record<string, string[]> = {}
|
|
55
|
+
|
|
56
|
+
for (const field in rules) {
|
|
57
|
+
const fieldRules = normalizeRules(rules[field])
|
|
58
|
+
|
|
59
|
+
if (field.includes("*")) {
|
|
60
|
+
// const [arrayPath, childPath] = field.split(".*.")
|
|
61
|
+
// const arr = getNestedValue(data, arrayPath)
|
|
62
|
+
|
|
63
|
+
// if (!Array.isArray(arr)) {
|
|
64
|
+
// addError(errors, arrayPath, `${arrayPath} harus berupa array`)
|
|
65
|
+
// continue
|
|
66
|
+
// }
|
|
67
|
+
|
|
68
|
+
// for (let i = 0; i < arr.length; i++) {
|
|
69
|
+
// const value = childPath
|
|
70
|
+
// ? getNestedValue(arr[i], childPath)
|
|
71
|
+
// : arr[i]
|
|
72
|
+
|
|
73
|
+
// const itemField = childPath
|
|
74
|
+
// ? `${arrayPath}.${i}.${childPath}`
|
|
75
|
+
// : `${arrayPath}.${i}`
|
|
76
|
+
|
|
77
|
+
// await checkRules({ field: itemField, value, rules: fieldRules, data, errors })
|
|
78
|
+
// }
|
|
79
|
+
const segments = field.split(".")
|
|
80
|
+
|
|
81
|
+
await nestedValidation({ value: data, segments, rules: fieldRules, fieldPath: "", data, errors })
|
|
82
|
+
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
const value = getNestedValue(data, field) ?? ""
|
|
88
|
+
|
|
89
|
+
await checkRules({ field, value, rules: fieldRules, data, errors })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
valid: Object.keys(errors).length === 0,
|
|
94
|
+
errors
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
async function checkRules({ field, value, rules, data, errors } : { field: string, value: any, rules: ValidationRule[], data: any, errors: Record<string, string[]> }) {
|
|
100
|
+
for (const rule of rules) {
|
|
101
|
+
const [name, param] = rule.split(":") as [string, string | undefined]
|
|
102
|
+
|
|
103
|
+
switch (name) {
|
|
104
|
+
// === BASIC ===
|
|
105
|
+
case "required":
|
|
106
|
+
if (validator.isEmpty(String(value).trim())) {
|
|
107
|
+
addError(errors, field, `${field} wajib diisi`)
|
|
108
|
+
}
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
case "string":
|
|
112
|
+
case "text":
|
|
113
|
+
if (typeof value !== "string") {
|
|
114
|
+
addError(errors, field, `${field} harus berupa string`)
|
|
115
|
+
}
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
case "numeric":
|
|
119
|
+
case "number":
|
|
120
|
+
if (!validator.isNumeric(String(value))) {
|
|
121
|
+
addError(errors, field, `${field} harus berupa angka`)
|
|
122
|
+
}
|
|
123
|
+
break
|
|
124
|
+
|
|
125
|
+
case "boolean":
|
|
126
|
+
if (!(value === true || value === false || value === "true" || value === "false" || value === 1 || value === 0)) {
|
|
127
|
+
addError(errors, field, `${field} harus berupa boolean`)
|
|
128
|
+
}
|
|
129
|
+
break
|
|
130
|
+
|
|
131
|
+
case "email":
|
|
132
|
+
if (!validator.isEmail(String(value))) {
|
|
133
|
+
addError(errors, field, `${field} harus berupa email yang valid`)
|
|
134
|
+
}
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
case "url":
|
|
138
|
+
if (!validator.isURL(String(value))) {
|
|
139
|
+
addError(errors, field, `${field} harus berupa URL yang valid`)
|
|
140
|
+
}
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
case "date":
|
|
144
|
+
if (!validator.isDate(String(value))) {
|
|
145
|
+
addError(errors, field, `${field} harus berupa tanggal yang valid`)
|
|
146
|
+
}
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
// === LENGTH ===
|
|
150
|
+
case "min": {
|
|
151
|
+
const min = parseInt(param!)
|
|
152
|
+
if (!validator.isLength(String(value), { min })) {
|
|
153
|
+
addError(errors, field, `${field} minimal ${min} karakter`)
|
|
154
|
+
}
|
|
155
|
+
break
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case "max": {
|
|
159
|
+
const max = parseInt(param!)
|
|
160
|
+
if (!validator.isLength(String(value), { max })) {
|
|
161
|
+
addError(errors, field, `${field} maksimal ${max} karakter`)
|
|
162
|
+
}
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case "between": {
|
|
167
|
+
const [minVal, maxVal] = param!.split(",").map(Number)
|
|
168
|
+
if (!validator.isLength(String(value), { min: minVal, max: maxVal })) {
|
|
169
|
+
addError(errors, field, `${field} harus antara ${minVal} - ${maxVal} karakter`)
|
|
170
|
+
}
|
|
171
|
+
break
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// === SET MEMBERSHIP ===
|
|
175
|
+
case "in": {
|
|
176
|
+
const allowed = param!.split(",")
|
|
177
|
+
if (!allowed.includes(String(value))) {
|
|
178
|
+
addError(errors, field, `${field} harus salah satu dari: ${allowed.join(", ")}`)
|
|
179
|
+
}
|
|
180
|
+
break
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
case "not_in": {
|
|
184
|
+
const notAllowed = param!.split(",")
|
|
185
|
+
if (notAllowed.includes(String(value))) {
|
|
186
|
+
addError(errors, field, `${field} tidak boleh salah satu dari: ${notAllowed.join(", ")}`)
|
|
187
|
+
}
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case "array":
|
|
192
|
+
if (!Array.isArray(value)) {
|
|
193
|
+
addError(errors, field, `${field} harus berupa array`)
|
|
194
|
+
}
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
// === RELATIONAL ===
|
|
198
|
+
case "confirmed":
|
|
199
|
+
if (value !== getNestedValue(data, `${field}_confirmation`)) {
|
|
200
|
+
addError(errors, field, `${field} tidak sama dengan konfirmasi`)
|
|
201
|
+
}
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
case "same":
|
|
205
|
+
if (value !== getNestedValue(data, param!)) {
|
|
206
|
+
addError(errors, field, `${field} harus sama dengan ${param}`)
|
|
207
|
+
}
|
|
208
|
+
break
|
|
209
|
+
|
|
210
|
+
case "different":
|
|
211
|
+
if (value === getNestedValue(data, param!)) {
|
|
212
|
+
addError(errors, field, `${field} harus berbeda dengan ${param}`)
|
|
213
|
+
}
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
// === REGEX ===
|
|
217
|
+
case "regex":
|
|
218
|
+
try {
|
|
219
|
+
const pattern = new RegExp(param!)
|
|
220
|
+
if (!pattern.test(String(value))) {
|
|
221
|
+
addError(errors, field, `${field} tidak sesuai format`)
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
addError(errors, field, `Regex rule untuk ${field} tidak valid`)
|
|
225
|
+
}
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
// === DATABASE VALIDATION ===
|
|
229
|
+
case "unique": {
|
|
230
|
+
const [table, column, exceptId] = param!.split(",")
|
|
231
|
+
const query = db.table(table).where(column, value)
|
|
232
|
+
if (exceptId) query.whereNot("id", exceptId)
|
|
233
|
+
const existing = await query.first()
|
|
234
|
+
if (existing) {
|
|
235
|
+
addError(errors, field, `${field} sudah digunakan`)
|
|
236
|
+
}
|
|
237
|
+
break
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
case "exists": {
|
|
241
|
+
const [table, column] = param!.split(",")
|
|
242
|
+
const existing = await db.table(table).where(column, value).first()
|
|
243
|
+
if (!existing) {
|
|
244
|
+
addError(errors, field, `${field} tidak ditemukan di ${table}`)
|
|
245
|
+
}
|
|
246
|
+
break
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async function nestedValidation({
|
|
255
|
+
value,
|
|
256
|
+
segments,
|
|
257
|
+
rules,
|
|
258
|
+
fieldPath,
|
|
259
|
+
data,
|
|
260
|
+
errors
|
|
261
|
+
}: {
|
|
262
|
+
value: any
|
|
263
|
+
segments: string[]
|
|
264
|
+
rules: ValidationRule[]
|
|
265
|
+
fieldPath: string
|
|
266
|
+
data: any
|
|
267
|
+
errors: Record<string, string[]>
|
|
268
|
+
}) {
|
|
269
|
+
if (segments.length === 0) {
|
|
270
|
+
await checkRules({
|
|
271
|
+
field: fieldPath,
|
|
272
|
+
value,
|
|
273
|
+
rules,
|
|
274
|
+
data,
|
|
275
|
+
errors
|
|
276
|
+
})
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const [segment, ...rest] = segments
|
|
281
|
+
|
|
282
|
+
if (segment === "*") {
|
|
283
|
+
if (!Array.isArray(value)) {
|
|
284
|
+
addError(errors, fieldPath, `${fieldPath} harus berupa array`)
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (let i = 0; i < value.length; i++) {
|
|
289
|
+
await nestedValidation({
|
|
290
|
+
value: value[i],
|
|
291
|
+
segments: rest,
|
|
292
|
+
rules,
|
|
293
|
+
fieldPath: `${fieldPath}.${i}`,
|
|
294
|
+
data,
|
|
295
|
+
errors
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
} else {
|
|
299
|
+
await nestedValidation({
|
|
300
|
+
value: value?.[segment],
|
|
301
|
+
segments: rest,
|
|
302
|
+
rules,
|
|
303
|
+
fieldPath: fieldPath ? `${fieldPath}.${segment}` : segment,
|
|
304
|
+
data,
|
|
305
|
+
errors
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
// ==================================>
|
|
313
|
+
// ## Validation helpers
|
|
314
|
+
// ==================================>
|
|
315
|
+
function getNestedValue(obj: any, path: string): any {
|
|
316
|
+
if (!obj || typeof obj !== "object") return undefined
|
|
317
|
+
|
|
318
|
+
const normalizedPath = path
|
|
319
|
+
.replace(/\[(\w+)\]/g, '.$1')
|
|
320
|
+
.replace(/\['([^']+)'\]/g, '.$1')
|
|
321
|
+
.replace(/\["([^"]+)"\]/g, '.$1')
|
|
322
|
+
|
|
323
|
+
return normalizedPath.split('.').reduce((acc, key) => {
|
|
324
|
+
if (acc && Object.prototype.hasOwnProperty.call(acc, key)) {
|
|
325
|
+
return acc[key]
|
|
326
|
+
}
|
|
327
|
+
return undefined
|
|
328
|
+
}, obj)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function normalizeRules(rules: ValidationRule[] | string): ValidationRule[] {
|
|
332
|
+
if (Array.isArray(rules)) return rules
|
|
333
|
+
return rules.split("|") as ValidationRule[]
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function addError(errors: Record<string, string[]>, field: string, message: string) {
|
|
337
|
+
errors[field] = [...(errors[field] || []), message]
|
|
338
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"ignoreDeprecations": "6.0",
|
|
4
|
+
"target": "ES2021",
|
|
5
|
+
"module": "ES2022",
|
|
6
|
+
"moduleResolution": "node",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"sourceMap": true,
|
|
9
|
+
"outDir": "./dist",
|
|
10
|
+
"rootDir": "./src",
|
|
11
|
+
"strict": true,
|
|
12
|
+
"esModuleInterop": true,
|
|
13
|
+
"skipLibCheck": true,
|
|
14
|
+
"forceConsistentCasingInFileNames": true,
|
|
15
|
+
"baseUrl": ".",
|
|
16
|
+
"types": ["bun-types", "elysia"],
|
|
17
|
+
"paths": {
|
|
18
|
+
"@utils": ["src/index.ts"],
|
|
19
|
+
"@utils/*": ["src/*"]
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"include": ["src/**/*"]
|
|
23
|
+
}
|