galbe 0.1.7 → 0.1.13
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/.github/workflows/deploy_website.yml +1 -1
- package/.github/workflows/release.yml +1 -1
- package/README.md +3 -1
- package/docs/context.md +114 -0
- package/docs/error-handler.md +54 -0
- package/docs/getting-started.md +25 -25
- package/docs/handler.md +105 -0
- package/docs/hooks.md +90 -0
- package/docs/plugins.md +1 -0
- package/docs/router.md +10 -0
- package/docs/routes.md +12 -12
- package/docs/schemas.md +24 -28
- package/package.json +1 -1
- package/src/index.ts +4 -2
- package/src/parser.ts +63 -41
- package/src/router.ts +15 -11
- package/src/server.ts +32 -16
- package/src/types.ts +15 -9
- package/test/hooks.test.ts +23 -0
- package/test/router.test.ts +2 -6
package/docs/schemas.md
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
# Shemas
|
|
2
2
|
|
|
3
|
-
Galbe
|
|
4
|
-
|
|
5
|
-
The prime intention of that features is to offer an easy way to manage automatically request inputs validation and error handling. Moreover, it also greatly improve developper's experience by infering static Typescript types from schema definitions.
|
|
3
|
+
Galbe provides a custom Schema Type processor that offers type safety, data parsing, and validation. The primary purpose of this feature is to simplify request input validation and error handling automatically. Additionally, it enhances the developer's experience by inferring static TypeScript types from schema definitions.
|
|
6
4
|
|
|
7
5
|
## Schema Types
|
|
8
6
|
|
|
9
|
-
To
|
|
7
|
+
To start using Schema definitions, import `$T` from the `galbe` library:
|
|
10
8
|
|
|
11
9
|
```js
|
|
12
10
|
import { $T } from 'galbe'
|
|
@@ -62,32 +60,32 @@ Schema Type matching `array` values.
|
|
|
62
60
|
const arraySchema = $T.array($T.any(), options)
|
|
63
61
|
```
|
|
64
62
|
|
|
65
|
-
####
|
|
63
|
+
#### Optional
|
|
66
64
|
|
|
67
|
-
|
|
65
|
+
Makes any type optional. This allows for `undefined` values.
|
|
68
66
|
|
|
69
67
|
```ts
|
|
70
|
-
const
|
|
68
|
+
const optionalSchema = $T.optional($T.string())
|
|
71
69
|
```
|
|
72
70
|
|
|
73
|
-
####
|
|
71
|
+
#### Union
|
|
74
72
|
|
|
75
|
-
|
|
73
|
+
Creates an union of Schema Types.
|
|
76
74
|
|
|
77
75
|
```ts
|
|
78
|
-
const
|
|
76
|
+
const unionSchema = $T.union([$T.string(), $T.number()])
|
|
79
77
|
```
|
|
80
78
|
|
|
81
79
|
## Request Schema definition
|
|
82
80
|
|
|
83
|
-
The Request Schema definition allows you to define a schema for your request on your [Route Definition](). It must be defined right after the
|
|
81
|
+
The Request Schema definition allows you to define a schema for your request on your [Route Definition](routes.md#route-defintion). It must be defined right after the path of your route.
|
|
84
82
|
|
|
85
83
|
```js
|
|
86
84
|
const schema = {}
|
|
87
85
|
galbe.get('/foo/:bar', schema, ctx => {})
|
|
88
86
|
```
|
|
89
87
|
|
|
90
|
-
The Request Schema has
|
|
88
|
+
The Request Schema has four optional properties:
|
|
91
89
|
|
|
92
90
|
### headers
|
|
93
91
|
|
|
@@ -95,7 +93,7 @@ The Request Schema has 4 optional properties
|
|
|
95
93
|
headers: { [key: string]: STString | STBoolean | STNumber | STInteger | STLiteral }
|
|
96
94
|
```
|
|
97
95
|
|
|
98
|
-
This is a key-value object where each key represents a request
|
|
96
|
+
This is a key-value object where each key represents a request header name, and the value is the associated Schema.
|
|
99
97
|
|
|
100
98
|
**Example**:
|
|
101
99
|
|
|
@@ -113,7 +111,7 @@ const schema = {
|
|
|
113
111
|
params: { [key: string]: STString | STBoolean | STNumber | STInteger | STLiteral }
|
|
114
112
|
```
|
|
115
113
|
|
|
116
|
-
This is a key-value object where each key represents a request
|
|
114
|
+
This is a key-value object where each key represents a request path parameter name, and the value is the associated Schema.
|
|
117
115
|
|
|
118
116
|
**Example**:
|
|
119
117
|
|
|
@@ -127,7 +125,7 @@ const schema = {
|
|
|
127
125
|
```
|
|
128
126
|
|
|
129
127
|
> [!WARNING]
|
|
130
|
-
> Every key should match an existing [route path]() parameter. Otherwise Typescript will show
|
|
128
|
+
> Every key should match an existing [route path](routes.md#route-defintion) parameter. Otherwise Typescript will show an error.
|
|
131
129
|
>
|
|
132
130
|
> By default, if no schema is defined for a given parameter. Galbe will assume it is of type `string`.
|
|
133
131
|
|
|
@@ -137,7 +135,7 @@ const schema = {
|
|
|
137
135
|
query: { [key: string]: STString | STBoolean | STNumber | STInteger | STLiteral }
|
|
138
136
|
```
|
|
139
137
|
|
|
140
|
-
This is a key-value object where each key represents a request
|
|
138
|
+
This is a key-value object where each key represents a request query parameter name, and the value is the associated Schema.
|
|
141
139
|
|
|
142
140
|
**Example**:
|
|
143
141
|
|
|
@@ -152,13 +150,15 @@ const schema = {
|
|
|
152
150
|
|
|
153
151
|
### body
|
|
154
152
|
|
|
153
|
+
<!-- prettier-ignore -->
|
|
155
154
|
```ts
|
|
156
|
-
body: STByteArray | STString | STBoolean | STNumber | STInteger | STLiteral |
|
|
155
|
+
body: STByteArray | STString | STBoolean | STNumber | STInteger | STLiteral |
|
|
156
|
+
STObject | STMulripartForm | STUrlForm
|
|
157
157
|
```
|
|
158
158
|
|
|
159
159
|
#### Json
|
|
160
160
|
|
|
161
|
-
To define an `application/json` request body
|
|
161
|
+
To define an `application/json` request body, use `STObject` Schema Type. Example:
|
|
162
162
|
|
|
163
163
|
```ts
|
|
164
164
|
const jsonBody = $T.object({
|
|
@@ -169,7 +169,7 @@ const jsonBody = $T.object({
|
|
|
169
169
|
|
|
170
170
|
#### Multipart
|
|
171
171
|
|
|
172
|
-
To define a `multipart/form-data` request body
|
|
172
|
+
To define a `multipart/form-data` request body, use `TMultipartForm` Schema Type. Example:
|
|
173
173
|
|
|
174
174
|
```ts
|
|
175
175
|
const multipartBody = $T.multipartForm({
|
|
@@ -180,7 +180,7 @@ const multipartBody = $T.multipartForm({
|
|
|
180
180
|
|
|
181
181
|
#### Url Form
|
|
182
182
|
|
|
183
|
-
To define an `application/x-www-form-urlencoded` request body
|
|
183
|
+
To define an `application/x-www-form-urlencoded` request body, use `TUrlForm` Schema Type. Example:
|
|
184
184
|
|
|
185
185
|
```ts
|
|
186
186
|
const urlBody = $T.urlForm({
|
|
@@ -191,11 +191,9 @@ const urlBody = $T.urlForm({
|
|
|
191
191
|
|
|
192
192
|
#### Stream
|
|
193
193
|
|
|
194
|
-
Some body request types can be streamed by using `STStream` Schema Type wrapper.
|
|
194
|
+
Some body request types can be streamed by using `STStream` Schema Type wrapper. The streamable Schema Types are `STByteArray`, `STString`, `STUrlForm` and `STMultipartForm`. This can be usefull to imporve performances in case you have heavy body payloads and you want to perform early validations on the body.
|
|
195
195
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
Let's see a concrete example where that could be usefull. Imagine you want a `multipart/form-data` body request that has two properties `username` and `heavyImageFile`. In the normal case you would define something like that:
|
|
196
|
+
Let's look at a concrete example where this could be useful. Imagine you want a `multipart/form-data` body request that has two properties: `username` and `heavyImageFile`. In a normal case, you would define something like this:
|
|
199
197
|
|
|
200
198
|
```ts
|
|
201
199
|
galbe.post(
|
|
@@ -215,11 +213,9 @@ galbe.post(
|
|
|
215
213
|
})
|
|
216
214
|
```
|
|
217
215
|
|
|
218
|
-
This means that in the case where the username wouldn't pass the validation, the full request body including the `heavyImageFile
|
|
219
|
-
|
|
220
|
-
The `STStream` Schema Type wrapper was created to remediate to that issue. In practice it allows you to perform validations on the fly.
|
|
216
|
+
This means that in the case where the username wouldn't pass the validation, the full request body, including the `heavyImageFile`, would have been processed for nothing, as it is not used. This would induce unnecessary time and resource consumption.
|
|
221
217
|
|
|
222
|
-
Now in your handler, instead of receiving an object as ctx.body
|
|
218
|
+
The `STStream` Schema Type wrapper was created to remediate to remediate this issue. In practice it allows you to perform validations on the fly. Now in your handler, instead of receiving an object as `ctx.body`, you will receive an [AsyncGenerator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator).
|
|
223
219
|
|
|
224
220
|
```ts
|
|
225
221
|
galbe.post(
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -80,7 +80,6 @@ const galbeMethod = <
|
|
|
80
80
|
[header: string]: string
|
|
81
81
|
}
|
|
82
82
|
status?: number
|
|
83
|
-
redirect?: string
|
|
84
83
|
}
|
|
85
84
|
}
|
|
86
85
|
return {
|
|
@@ -123,7 +122,10 @@ export class Galbe {
|
|
|
123
122
|
constructor(config?: GalbeConfig) {
|
|
124
123
|
this.config = config ?? {}
|
|
125
124
|
this.config.routes = this.config.routes ?? true
|
|
126
|
-
this.router = new GalbeRouter(
|
|
125
|
+
this.router = new GalbeRouter({
|
|
126
|
+
prefix: this.config?.basePath || '',
|
|
127
|
+
cacheEnabled: this.config?.router?.cacheEnabled
|
|
128
|
+
})
|
|
127
129
|
}
|
|
128
130
|
private add(route: any) {
|
|
129
131
|
this.router.add(route)
|
package/src/parser.ts
CHANGED
|
@@ -9,16 +9,14 @@ import type {
|
|
|
9
9
|
MultipartFormData,
|
|
10
10
|
STUrlFormValues,
|
|
11
11
|
STMultipartFormValues,
|
|
12
|
-
STUnion,
|
|
13
12
|
STLiteral,
|
|
14
|
-
STArray,
|
|
15
13
|
STSchema
|
|
16
14
|
} from './schema'
|
|
17
15
|
|
|
18
16
|
import { readableStreamToArrayBuffer } from 'bun'
|
|
19
17
|
import { Kind, Optional, Stream } from './schema'
|
|
20
18
|
import { validate } from './validator'
|
|
21
|
-
import {
|
|
19
|
+
import { InternalError, RequestError } from './index'
|
|
22
20
|
|
|
23
21
|
const textDecoder = new TextDecoder()
|
|
24
22
|
const textEncoder = new TextEncoder()
|
|
@@ -39,10 +37,10 @@ const MP_HEADER_RX = /^multipart\/form-data/
|
|
|
39
37
|
|
|
40
38
|
export const requestBodyParser = async (
|
|
41
39
|
body: ReadableStream | null,
|
|
42
|
-
headers:
|
|
40
|
+
headers: Record<string, string>,
|
|
43
41
|
schema?: STBody
|
|
44
42
|
) => {
|
|
45
|
-
const
|
|
43
|
+
const contentType = headers?.['content-type']
|
|
46
44
|
const kind = schema?.[Kind]
|
|
47
45
|
const isStream = schema && Stream in schema
|
|
48
46
|
try {
|
|
@@ -60,19 +58,19 @@ export const requestBodyParser = async (
|
|
|
60
58
|
} else if (schema?.[Optional]) {
|
|
61
59
|
return null
|
|
62
60
|
} else {
|
|
63
|
-
throw new RequestError({ status: 400,
|
|
61
|
+
throw new RequestError({ status: 400, payload: { body: `Not a valid ${kind}` } })
|
|
64
62
|
}
|
|
65
63
|
} else {
|
|
66
64
|
if (contentType === BA_HEADER) {
|
|
67
65
|
return new Uint8Array()
|
|
68
66
|
} else if (contentType === JSON_HEADER) {
|
|
69
|
-
throw new RequestError({ status: 400,
|
|
67
|
+
throw new RequestError({ status: 400, payload: { body: 'Not a valid json' } })
|
|
70
68
|
} else if (contentType?.match(TXT_HEADER_RX)) {
|
|
71
|
-
throw new RequestError({ status: 400,
|
|
69
|
+
throw new RequestError({ status: 400, payload: { body: 'Not a valid text' } })
|
|
72
70
|
} else if (contentType?.match(FORM_HEADER_RX)) {
|
|
73
|
-
throw new RequestError({ status: 400,
|
|
71
|
+
throw new RequestError({ status: 400, payload: { body: 'Not a valid form' } })
|
|
74
72
|
} else if (contentType?.match(MP_HEADER_RX)) {
|
|
75
|
-
throw new RequestError({ status: 400,
|
|
73
|
+
throw new RequestError({ status: 400, payload: { body: 'Not a valid multipart form' } })
|
|
76
74
|
} else return null
|
|
77
75
|
}
|
|
78
76
|
} else {
|
|
@@ -91,7 +89,7 @@ export const requestBodyParser = async (
|
|
|
91
89
|
: contentType?.match(MP_HEADER_RX)
|
|
92
90
|
? 'multipartForm'
|
|
93
91
|
: contentType
|
|
94
|
-
throw new RequestError({ status: 400,
|
|
92
|
+
throw new RequestError({ status: 400, payload: { body: `Expected ${kind}, received ${received}` } })
|
|
95
93
|
} else if (!contentType || contentType === BA_HEADER) {
|
|
96
94
|
if (!schema) {
|
|
97
95
|
if (isStream) return rsToAsyncIterator(body)
|
|
@@ -104,7 +102,14 @@ export const requestBodyParser = async (
|
|
|
104
102
|
}
|
|
105
103
|
} else if (contentType === JSON_HEADER) {
|
|
106
104
|
if (!schema) {
|
|
107
|
-
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(await streamToString(body))
|
|
107
|
+
} catch (err: any) {
|
|
108
|
+
throw new RequestError({
|
|
109
|
+
status: 400,
|
|
110
|
+
payload: { body: err?.message ?? 'Parsing error' }
|
|
111
|
+
})
|
|
112
|
+
}
|
|
108
113
|
} else {
|
|
109
114
|
const str = await streamToString(body)
|
|
110
115
|
let json
|
|
@@ -113,7 +118,7 @@ export const requestBodyParser = async (
|
|
|
113
118
|
} catch (err: any) {
|
|
114
119
|
throw new RequestError({
|
|
115
120
|
status: 400,
|
|
116
|
-
|
|
121
|
+
payload: { body: err?.message ?? 'Parsing error' }
|
|
117
122
|
})
|
|
118
123
|
}
|
|
119
124
|
return validate(json, schema, true)
|
|
@@ -129,7 +134,7 @@ export const requestBodyParser = async (
|
|
|
129
134
|
return await streamToUrlForm(body)
|
|
130
135
|
} else {
|
|
131
136
|
if (kind !== 'urlForm')
|
|
132
|
-
throw new RequestError({ status: 400,
|
|
137
|
+
throw new RequestError({ status: 400, payload: { body: `Expected ${kind}, received urlForm` } })
|
|
133
138
|
if (isStream) return $streamToUrlForm(body, schema as STStream<STUrlForm>)
|
|
134
139
|
else return await streamToUrlForm(body, schema as STUrlForm)
|
|
135
140
|
}
|
|
@@ -139,7 +144,7 @@ export const requestBodyParser = async (
|
|
|
139
144
|
return await streamToMultipartForm(body, boundary)
|
|
140
145
|
} else {
|
|
141
146
|
if (kind !== 'multipartForm')
|
|
142
|
-
throw new RequestError({ status: 400,
|
|
147
|
+
throw new RequestError({ status: 400, payload: { body: `Expected ${kind}, received MultipartForm` } })
|
|
143
148
|
if (isStream) return $streamToMultipartForm(body, boundary, schema as STStream<STMultipartForm>)
|
|
144
149
|
else {
|
|
145
150
|
return streamToMultipartForm(body, boundary, schema as STMultipartForm)
|
|
@@ -151,7 +156,7 @@ export const requestBodyParser = async (
|
|
|
151
156
|
}
|
|
152
157
|
} catch (error) {
|
|
153
158
|
if (error instanceof RequestError) throw error
|
|
154
|
-
else throw new RequestError({ status: 400,
|
|
159
|
+
else throw new RequestError({ status: 400, payload: { body: error } })
|
|
155
160
|
}
|
|
156
161
|
}
|
|
157
162
|
async function* $streamToString(body: ReadableStream) {
|
|
@@ -189,7 +194,7 @@ async function* $streamToUrlForm(
|
|
|
189
194
|
let s = schema?.props?.[key]?.[Kind] === 'array' ? schema?.props?.[key].items : schema?.props?.[key]
|
|
190
195
|
val = s ? paramParser(val, s) : val
|
|
191
196
|
} catch (error) {
|
|
192
|
-
throw new RequestError({ status: 400,
|
|
197
|
+
throw new RequestError({ status: 400, payload: { body: { [key]: error } } })
|
|
193
198
|
}
|
|
194
199
|
delete required[key]
|
|
195
200
|
yield [key, val]
|
|
@@ -220,7 +225,7 @@ async function* $streamToUrlForm(
|
|
|
220
225
|
let s = schema?.props?.[key]?.[Kind] === 'array' ? schema?.props?.[key].items : schema?.props?.[key]
|
|
221
226
|
val = s ? paramParser(val, s) : val
|
|
222
227
|
} catch (error) {
|
|
223
|
-
throw new RequestError({ status: 400,
|
|
228
|
+
throw new RequestError({ status: 400, payload: { body: { [key]: error } } })
|
|
224
229
|
}
|
|
225
230
|
delete required[key]
|
|
226
231
|
yield [key, val]
|
|
@@ -234,7 +239,7 @@ async function* $streamToUrlForm(
|
|
|
234
239
|
if (reqKeys.length > 0)
|
|
235
240
|
throw new RequestError({
|
|
236
241
|
status: 400,
|
|
237
|
-
|
|
242
|
+
payload: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
|
|
238
243
|
})
|
|
239
244
|
}
|
|
240
245
|
const streamToUrlForm = async (body: ReadableStream<Uint8Array>, schema?: STUrlForm) => {
|
|
@@ -260,7 +265,7 @@ const streamToUrlForm = async (body: ReadableStream<Uint8Array>, schema?: STUrlF
|
|
|
260
265
|
errors[k] = k in errors ? [...errors[k], error] : error
|
|
261
266
|
}
|
|
262
267
|
}
|
|
263
|
-
if (Object.keys(errors).length) throw new RequestError({ status: 400,
|
|
268
|
+
if (Object.keys(errors).length) throw new RequestError({ status: 400, payload: { body: errors } })
|
|
264
269
|
for (const [k, s] of Object.entries(required)) {
|
|
265
270
|
if (s[Kind] === 'array') {
|
|
266
271
|
object[k] = []
|
|
@@ -271,7 +276,7 @@ const streamToUrlForm = async (body: ReadableStream<Uint8Array>, schema?: STUrlF
|
|
|
271
276
|
if (reqKeys.length > 0)
|
|
272
277
|
throw new RequestError({
|
|
273
278
|
status: 400,
|
|
274
|
-
|
|
279
|
+
payload: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
|
|
275
280
|
})
|
|
276
281
|
return object
|
|
277
282
|
}
|
|
@@ -310,7 +315,7 @@ async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundar
|
|
|
310
315
|
}
|
|
311
316
|
} catch (err) {
|
|
312
317
|
if (err instanceof RequestError) throw err
|
|
313
|
-
throw new RequestError({ status: 400,
|
|
318
|
+
throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
|
|
314
319
|
}
|
|
315
320
|
}
|
|
316
321
|
bK = new Uint8Array()
|
|
@@ -343,7 +348,7 @@ async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundar
|
|
|
343
348
|
content: parseMultipartContent(bV, headers, schema)
|
|
344
349
|
}
|
|
345
350
|
} catch (err) {
|
|
346
|
-
throw new RequestError({ status: 400,
|
|
351
|
+
throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
|
|
347
352
|
}
|
|
348
353
|
}
|
|
349
354
|
for (const [k, s] of Object.entries(required)) {
|
|
@@ -356,7 +361,7 @@ async function* $streamToMultipartForm(data: ReadableStream<Uint8Array>, boundar
|
|
|
356
361
|
if (reqKeys.length > 0)
|
|
357
362
|
throw new RequestError({
|
|
358
363
|
status: 400,
|
|
359
|
-
|
|
364
|
+
payload: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
|
|
360
365
|
})
|
|
361
366
|
}
|
|
362
367
|
const parseMultipartHeader = (header: string): { name: string; [key: string]: string } | null => {
|
|
@@ -394,19 +399,22 @@ const parseMultipartContent = (
|
|
|
394
399
|
try {
|
|
395
400
|
result = JSON.parse(textDecoder.decode(content).trim())
|
|
396
401
|
} catch (err: any) {
|
|
397
|
-
throw new RequestError({ status: 400,
|
|
402
|
+
throw new RequestError({ status: 400, payload: { body: { [headers.name]: err?.message || 'Parsing error' } } })
|
|
398
403
|
}
|
|
399
404
|
} else if (schema?.props) {
|
|
400
405
|
if (schema?.props[headers.name][Kind] === 'object') {
|
|
401
406
|
try {
|
|
402
407
|
result = JSON.parse(textDecoder.decode(content).trim())
|
|
403
408
|
} catch (err: any) {
|
|
404
|
-
throw new RequestError({
|
|
409
|
+
throw new RequestError({
|
|
410
|
+
status: 400,
|
|
411
|
+
payload: { body: { [headers.name]: err?.message || 'Parsing error' } }
|
|
412
|
+
})
|
|
405
413
|
}
|
|
406
414
|
try {
|
|
407
415
|
validate(result, schema?.props[headers.name])
|
|
408
416
|
} catch (err) {
|
|
409
|
-
throw new RequestError({ status: 400,
|
|
417
|
+
throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
|
|
410
418
|
}
|
|
411
419
|
} else if (schema?.props[headers.name][Kind] === 'byteArray') {
|
|
412
420
|
return content
|
|
@@ -415,7 +423,7 @@ const parseMultipartContent = (
|
|
|
415
423
|
} else {
|
|
416
424
|
throw new RequestError({
|
|
417
425
|
status: 400,
|
|
418
|
-
|
|
426
|
+
payload: { body: { [headers.name]: `Expect ${schema?.props[headers.name][Kind]} found json` } }
|
|
419
427
|
})
|
|
420
428
|
}
|
|
421
429
|
}
|
|
@@ -424,7 +432,7 @@ const parseMultipartContent = (
|
|
|
424
432
|
let s = schema?.props[headers.name]
|
|
425
433
|
validate(result, s?.[Kind] === 'array' ? s?.items : s)
|
|
426
434
|
} catch (err) {
|
|
427
|
-
throw new RequestError({ status: 400,
|
|
435
|
+
throw new RequestError({ status: 400, payload: { body: { [headers.name]: err } } })
|
|
428
436
|
}
|
|
429
437
|
}
|
|
430
438
|
return result
|
|
@@ -473,7 +481,7 @@ const streamToMultipartForm = async (data: ReadableStream<Uint8Array>, boundary:
|
|
|
473
481
|
if (Object.keys(errors).length)
|
|
474
482
|
throw new RequestError({
|
|
475
483
|
status: 400,
|
|
476
|
-
|
|
484
|
+
payload: { body: errors }
|
|
477
485
|
})
|
|
478
486
|
for (const [k, s] of Object.entries(required)) {
|
|
479
487
|
if (s[Kind] === 'array') {
|
|
@@ -485,7 +493,7 @@ const streamToMultipartForm = async (data: ReadableStream<Uint8Array>, boundary:
|
|
|
485
493
|
if (reqKeys.length > 0)
|
|
486
494
|
throw new RequestError({
|
|
487
495
|
status: 400,
|
|
488
|
-
|
|
496
|
+
payload: { body: `Missing field${reqKeys.length > 1 ? 's' : ''}: ${reqKeys.join(', ')}` }
|
|
489
497
|
})
|
|
490
498
|
return res
|
|
491
499
|
}
|
|
@@ -559,14 +567,28 @@ const paramParser = (
|
|
|
559
567
|
}
|
|
560
568
|
|
|
561
569
|
export const requestPathParser = (input: string, path: string) => {
|
|
562
|
-
let
|
|
563
|
-
let pInput = input.replace(/^\/$(.*)\/?$/, '$1').split('/')
|
|
570
|
+
let pInput = input.split('/')
|
|
564
571
|
let params: Record<string, any> = {}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
572
|
+
let idx = 0
|
|
573
|
+
for (let i = 0; i < path.length; i++) {
|
|
574
|
+
let c = path[i]
|
|
575
|
+
if (c === '/') {
|
|
576
|
+
idx++
|
|
577
|
+
continue
|
|
578
|
+
}
|
|
579
|
+
if (c === ':') {
|
|
580
|
+
let name = ''
|
|
581
|
+
while (true) {
|
|
582
|
+
i++
|
|
583
|
+
c = path[i]
|
|
584
|
+
if (c === '/' || i >= path.length) {
|
|
585
|
+
i--
|
|
586
|
+
break
|
|
587
|
+
}
|
|
588
|
+
name += c
|
|
589
|
+
}
|
|
590
|
+
params[name] = pInput[idx]
|
|
591
|
+
}
|
|
570
592
|
}
|
|
571
593
|
return params
|
|
572
594
|
}
|
|
@@ -601,7 +623,7 @@ export const parseEntry = <T extends STProps>(
|
|
|
601
623
|
})
|
|
602
624
|
|
|
603
625
|
if (Object.keys(errors).length) {
|
|
604
|
-
throw new RequestError({ status: 400,
|
|
626
|
+
throw new RequestError({ status: 400, payload: options?.name ? { [options.name]: errors } : errors })
|
|
605
627
|
}
|
|
606
628
|
|
|
607
629
|
return parsedParams as Static<STObject<T>>
|
|
@@ -631,7 +653,7 @@ export const responseParser = (response: any, ctx: Context) => {
|
|
|
631
653
|
let data = `id:${id}\ndata:${r}\n\n`
|
|
632
654
|
try {
|
|
633
655
|
await controller.write(data)
|
|
634
|
-
|
|
656
|
+
await controller.flush()
|
|
635
657
|
} catch (err) {
|
|
636
658
|
console.error(err)
|
|
637
659
|
}
|
|
@@ -648,7 +670,7 @@ export const responseParser = (response: any, ctx: Context) => {
|
|
|
648
670
|
return new Response(JSON.stringify(response), details)
|
|
649
671
|
} catch (error) {
|
|
650
672
|
console.error(error)
|
|
651
|
-
throw new
|
|
673
|
+
throw new InternalError()
|
|
652
674
|
}
|
|
653
675
|
}
|
|
654
676
|
}
|
package/src/router.ts
CHANGED
|
@@ -44,19 +44,21 @@ const walkRoutes = (path: string[], node: RouteNode, alts: RouteNode[] = []): Ro
|
|
|
44
44
|
export class GalbeRouter {
|
|
45
45
|
routes: RouteTree
|
|
46
46
|
prefix: string
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
cacheEnabled: boolean
|
|
48
|
+
cachedRoutes: Map<string, Route | null>
|
|
49
|
+
constructor(options?: { prefix?: string; cacheEnabled?: boolean }) {
|
|
49
50
|
this.routes = { GET: {}, POST: {}, PUT: {}, PATCH: {}, DELETE: {}, OPTIONS: {} }
|
|
50
|
-
prefix = prefix || ''
|
|
51
|
+
let prefix = options?.prefix || ''
|
|
51
52
|
if (prefix && !prefix.match(/^\//)) prefix = `/${prefix}`
|
|
52
53
|
this.prefix = prefix
|
|
53
|
-
this.
|
|
54
|
+
this.cachedRoutes = new Map()
|
|
55
|
+
this.cacheEnabled = options?.cacheEnabled ?? false
|
|
54
56
|
}
|
|
55
57
|
add(route: Route) {
|
|
56
58
|
route.path = route?.path?.[0] === '/' ? route.path : `/${route.path}`
|
|
57
59
|
if (!route.path.match(ROUTE_REGEX)) throw new SyntaxError(`${route.path} is not a valid route path.`)
|
|
58
60
|
const isStatic = !route.path.match(/(:[\w\d-]+|\*)/)
|
|
59
|
-
if (isStatic) this.
|
|
61
|
+
if (isStatic) this.cachedRoutes.set(`[${route.method.toUpperCase()}]${route.path}`, route)
|
|
60
62
|
route.path = `${this.prefix || ''}${route.path}`
|
|
61
63
|
let path = route.path.replace(/^\/$(.*)\/?$/, '$1').split('/')
|
|
62
64
|
path.shift()
|
|
@@ -87,14 +89,16 @@ export class GalbeRouter {
|
|
|
87
89
|
}
|
|
88
90
|
}
|
|
89
91
|
find(method: string, path: string) {
|
|
90
|
-
const staticRoute = this.
|
|
92
|
+
const staticRoute = this.cachedRoutes.get(`[${method}]${path}`)
|
|
93
|
+
if (staticRoute === null) throw new NotFoundError()
|
|
91
94
|
if (staticRoute !== undefined) return staticRoute
|
|
92
|
-
let parts = path
|
|
93
|
-
.replace(/\/+/g, '/')
|
|
94
|
-
.replace(/^\/$(.*)\/?$/, '$1')
|
|
95
|
-
.split('/')
|
|
95
|
+
let parts = path.split('/')
|
|
96
96
|
const route = walkRoutes(parts, this.routes[method]).route
|
|
97
|
-
if (!route)
|
|
97
|
+
if (!route) {
|
|
98
|
+
if (this.cacheEnabled) this.cachedRoutes.set(`[${method}]${path}`, null)
|
|
99
|
+
throw new NotFoundError()
|
|
100
|
+
}
|
|
101
|
+
if (this.cacheEnabled) this.cachedRoutes.set(`[${method}]${path}`, route)
|
|
98
102
|
return route
|
|
99
103
|
}
|
|
100
104
|
}
|
package/src/server.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { Context, Route } from './types'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { InternalError, RequestError } from './types'
|
|
4
4
|
import { parseEntry, requestBodyParser, requestPathParser, responseParser } from './parser'
|
|
5
5
|
import { Galbe } from './index'
|
|
6
6
|
|
|
7
7
|
const handleInternalError = (error: any) => {
|
|
8
8
|
console.error(error)
|
|
9
|
-
return new
|
|
9
|
+
return new InternalError()
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const setupPluginCallbacks = (galbe: Galbe) => ({
|
|
@@ -60,7 +60,6 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
60
60
|
let response: any = ''
|
|
61
61
|
try {
|
|
62
62
|
// find route
|
|
63
|
-
if (!url.pathname.match(new RegExp(`^${galbe.config?.basePath || ''}`))) throw new NotFoundError()
|
|
64
63
|
try {
|
|
65
64
|
route = router.find(req.method, url.pathname)
|
|
66
65
|
} catch (error) {
|
|
@@ -75,8 +74,10 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
75
74
|
|
|
76
75
|
// parse request
|
|
77
76
|
const schema = route.schema
|
|
78
|
-
|
|
79
|
-
let
|
|
77
|
+
const inHeaders: Record<string, any> = {}
|
|
78
|
+
for (let [k, v] of req.headers) inHeaders[k] = v
|
|
79
|
+
let inQuery: Record<string, any> = {}
|
|
80
|
+
for (let [k, v] of url.searchParams) inQuery[k] = v
|
|
80
81
|
let inParams = requestPathParser(url.pathname, route.path)
|
|
81
82
|
|
|
82
83
|
context.body = await requestBodyParser(req.body, inHeaders, schema.body)
|
|
@@ -109,7 +110,7 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
109
110
|
else throw handleInternalError(error)
|
|
110
111
|
}
|
|
111
112
|
if (errors.length) {
|
|
112
|
-
throw new RequestError({ status: 400,
|
|
113
|
+
throw new RequestError({ status: 400, payload: errors.reduce((acc, c) => ({ ...acc, ...c.payload }), {}) })
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
for (const cb of pluginsCb.beforeHandle) {
|
|
@@ -123,13 +124,18 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
123
124
|
handlerCalled = true
|
|
124
125
|
return route.handler(context)
|
|
125
126
|
}
|
|
126
|
-
const callChain = route.hooks.map((hook, idx) => ({
|
|
127
|
+
const callChain: { call: () => any }[] = route.hooks.map((hook, idx) => ({
|
|
127
128
|
call: async () => {
|
|
128
129
|
let nextCalled = false
|
|
129
130
|
let next = async () => {
|
|
130
|
-
|
|
131
|
+
if (nextCalled) console.error('Hook already called - ignored')
|
|
132
|
+
else {
|
|
133
|
+
nextCalled = true
|
|
134
|
+
await callChain[idx + 1].call()
|
|
135
|
+
}
|
|
131
136
|
}
|
|
132
|
-
await hook(context, next)
|
|
137
|
+
let r = await hook(context, next)
|
|
138
|
+
if (r) return r
|
|
133
139
|
if (!nextCalled && !handlerCalled) await next()
|
|
134
140
|
}
|
|
135
141
|
}))
|
|
@@ -139,8 +145,11 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
139
145
|
context.set.status = response instanceof Response ? response.status : 200
|
|
140
146
|
}
|
|
141
147
|
})
|
|
142
|
-
if (callChain.length > 1)
|
|
143
|
-
|
|
148
|
+
if (callChain.length > 1) {
|
|
149
|
+
let r = await callChain[0].call()
|
|
150
|
+
if (r) response = r
|
|
151
|
+
} else response = await handlerWrapper(context)
|
|
152
|
+
|
|
144
153
|
const parsedResponse = responseParser(response, context)
|
|
145
154
|
|
|
146
155
|
for (const cb of pluginsCb.afterHandle) {
|
|
@@ -151,22 +160,29 @@ export default async (galbe: Galbe, port?: number) => {
|
|
|
151
160
|
return parsedResponse
|
|
152
161
|
} catch (error) {
|
|
153
162
|
context.set.status = error instanceof RequestError ? error.status : 500
|
|
154
|
-
|
|
163
|
+
let customError
|
|
164
|
+
if (galbe.errorHandler) customError = responseParser(galbe.errorHandler(error, context), context)
|
|
165
|
+
if (customError) return customError
|
|
155
166
|
if (error instanceof RequestError) {
|
|
156
|
-
return new Response(JSON.stringify(error.
|
|
167
|
+
return new Response(JSON.stringify(error.payload), {
|
|
157
168
|
status: error.status,
|
|
158
169
|
headers: { 'Content-Type': 'application/json' }
|
|
159
170
|
})
|
|
160
171
|
}
|
|
161
|
-
return new Response('Internal Server Error', {
|
|
172
|
+
return new Response('"Internal Server Error"', {
|
|
173
|
+
status: 500,
|
|
174
|
+
headers: {
|
|
175
|
+
'content-type': 'application/json'
|
|
176
|
+
}
|
|
177
|
+
})
|
|
162
178
|
}
|
|
163
179
|
},
|
|
164
180
|
error(error) {
|
|
165
181
|
console.error(error)
|
|
166
|
-
return new Response('Internal Server Error', {
|
|
182
|
+
return new Response('"Internal Server Error"', {
|
|
167
183
|
status: 500,
|
|
168
184
|
headers: {
|
|
169
|
-
'
|
|
185
|
+
'content-type': 'application/json'
|
|
170
186
|
}
|
|
171
187
|
})
|
|
172
188
|
}
|